diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-21 08:38:29 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-21 08:38:29 +0300 |
| commit | 1edf0e1add29f7f342d38b595a7a563a75414d0c (patch) | |
| tree | 693137c689b8118960cad73231832fc235bc765a | |
| parent | 09f0ee28b6a7b7fc588a8a4e387d94ffb6848af3 (diff) | |
Implement MediaDetailScreen with metadata, favorite toggle, player routing (ya)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | player-android/lib/api/player_api_client.dart | 9 | ||||
| -rw-r--r-- | player-android/lib/screens/media_detail_screen.dart | 604 | ||||
| -rw-r--r-- | player-android/lib/utils/error_mappers.dart | 18 | ||||
| -rw-r--r-- | player-android/test/screens/media_detail_screen_test.dart | 516 |
4 files changed, 1139 insertions, 8 deletions
diff --git a/player-android/lib/api/player_api_client.dart b/player-android/lib/api/player_api_client.dart index 9e67d9e..e9d99a0 100644 --- a/player-android/lib/api/player_api_client.dart +++ b/player-android/lib/api/player_api_client.dart @@ -144,6 +144,15 @@ class PlayerApiClient { String thumbnailUrl(int mediaId) => '${rawDio.options.baseUrl}/api/v1/media/$mediaId/thumbnail'; + /// Returns the URL used to stream a media item. + /// + /// Mirrors [thumbnailUrl]: the API path `/api/v1/media/{id}/stream` is kept + /// in one place and Dio internals are never leaked into the UI layer. + /// The base URL is derived from the underlying Dio instance so it stays + /// consistent with every other API call. + String streamUrl(int mediaId) => + '${rawDio.options.baseUrl}/api/v1/media/$mediaId/stream'; + Future<void> regenerateThumbnail(int mediaId) => throw UnimplementedError(); Future<bool> toggleFavorite(int mediaId) => throw UnimplementedError(); diff --git a/player-android/lib/screens/media_detail_screen.dart b/player-android/lib/screens/media_detail_screen.dart index c705a98..d272076 100644 --- a/player-android/lib/screens/media_detail_screen.dart +++ b/player-android/lib/screens/media_detail_screen.dart @@ -1,20 +1,608 @@ +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'; -/// Media-detail screen — will display a single media item with player controls. +import '../app_routes.dart'; +import '../models/models.dart'; +import '../providers/api_client_provider.dart'; +import '../utils/error_mappers.dart'; + +// --------------------------------------------------------------------------- +// MediaDetailScreen +// --------------------------------------------------------------------------- + +/// Displays a single media item with its title, full metadata (codec, +/// resolution, duration, file size), a thumbnail banner, a favourite toggle, +/// tag chips, and a play button that routes to the correct player. /// -/// Currently a lightweight placeholder; feature implementation will replace -/// the body without touching the router or other screens. -class MediaDetailScreen extends StatelessWidget { +/// Design notes: +/// - [ConsumerStatefulWidget] is used so we can hold local loading/error +/// state, guard async continuations with [mounted], and call [setState] +/// to trigger rebuilds after the favourite toggle. +/// - [getMedia] is called from [initState] (via a post-frame callback so the +/// Riverpod ref is fully bound) and on pull-to-refresh. +/// - No `dio` import — error mapping is delegated to [mediaDetailErrorMessage] +/// in `error_mappers.dart` (Dependency Inversion Principle). +/// - The screen is split into multiple focused sub-widgets so the state +/// class stays well under 50 lines. +class MediaDetailScreen extends ConsumerStatefulWidget { + /// The string form of the media ID extracted from the '/media/:id' route. + final String mediaId; + const MediaDetailScreen({super.key, required this.mediaId}); - /// The string form of the media ID extracted from the route path parameter. - final String mediaId; + @override + ConsumerState<MediaDetailScreen> createState() => _MediaDetailScreenState(); +} + +class _MediaDetailScreenState extends ConsumerState<MediaDetailScreen> { + // Nullable: null means the first load has not completed yet. + Media? _media; + + // Non-null when the last load attempt failed. + String? _error; + + // True while a getMedia call is in flight (shows the full-screen spinner). + bool _isLoading = false; + + // True while a toggleFavorite call is in flight; prevents concurrent taps + // from queuing up multiple API calls that could result in a desync. + bool _isFavoriteLoading = 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 the media item from the server and updates local state. + /// + /// Called on first mount and on pull-to-refresh. Errors are mapped by the + /// top-level [mediaDetailErrorMessage] helper so no `dio` import is needed. + Future<void> _load() async { + if (!mounted) return; + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final id = int.tryParse(widget.mediaId) ?? 0; + final client = ref.read(apiClientProvider); + final media = await client.getMedia(id); + if (!mounted) return; + setState(() { + _media = media; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = mediaDetailErrorMessage(e); + _isLoading = false; + }); + } + } + + // --------------------------------------------------------------------------- + // Favourite toggle + // --------------------------------------------------------------------------- + + /// Calls [toggleFavorite] on the server and reflects the new state locally. + /// + /// The server returns the new favourite state; we apply it to the in-memory + /// [_media] copy so the UI updates immediately without a full reload. + /// If the call fails, a snack-bar is shown and the toggle is reverted + /// (the local state was not yet changed, so no explicit revert is needed). + /// + /// [_isFavoriteLoading] is set to true for the duration of the call to block + /// concurrent taps that could otherwise race and desync the UI with the server. + Future<void> _toggleFavorite() async { + final media = _media; + // Guard against concurrent taps and against toggling before data is loaded. + if (media == null || _isFavoriteLoading) return; + + setState(() => _isFavoriteLoading = true); + + // Optimistically flip the favourite flag in local state so the icon + // updates instantly without waiting for the round-trip. + final newFavorite = !media.favorite; + if (!mounted) return; + setState(() { + _media = _buildMediaWithFavorite(media, newFavorite); + }); + + try { + final client = ref.read(apiClientProvider); + final confirmed = await client.toggleFavorite(media.id); + if (!mounted) return; + // Reconcile with the value the server actually stored. + setState(() { + _media = _buildMediaWithFavorite(_media!, confirmed); + }); + } catch (e) { + if (!mounted) return; + // Revert the optimistic update on failure. + setState(() { + _media = _buildMediaWithFavorite(_media!, media.favorite); + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not update favourite. Try again.')), + ); + } finally { + if (mounted) setState(() => _isFavoriteLoading = false); + } + } + + /// Returns a copy of [media] with [favorite] replaced. + /// + /// [Media] is immutable so we reconstruct it via [Media.fromJson]/[toJson] + /// to avoid adding a `copyWith` method to the model layer. + Media _buildMediaWithFavorite(Media media, bool favorite) { + final json = media.toJson()..['favorite'] = favorite; + return Media.fromJson(json); + } + + // --------------------------------------------------------------------------- + // Navigation + // --------------------------------------------------------------------------- + + /// Routes to the video or audio player based on [media.type]. + /// + /// The stream URL is obtained via [PlayerApiClient.streamUrl] — keeping the + /// API path in one place and preventing Dio internals from leaking into the + /// UI layer (Dependency Inversion). The URL is passed as a route extra so + /// the player screen can start playback without a second API call. + void _play() { + final media = _media; + if (media == null) return; + + final client = ref.read(apiClientProvider); + // Delegate URL construction to the client; avoids coupling the screen to + // the underlying Dio base URL or request structure. + final streamUrl = client.streamUrl(media.id); + + if (media.type == 'video') { + context.go( + AppRoutes.videoPlayerPath(media.id.toString()), + extra: streamUrl, + ); + } else { + // audio / podcast / unknown — default to the audio player. + context.go( + AppRoutes.audioPlayerPath(media.id.toString()), + extra: streamUrl, + ); + } + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text('Media $mediaId')), - body: Center(child: Text('Media detail for ID $mediaId – placeholder')), + appBar: _buildAppBar(), + body: _buildBody(context), + ); + } + + /// Builds the app bar; shows the media title when available. + AppBar _buildAppBar() { + return AppBar( + title: Text(_media?.fileName ?? 'Media ${widget.mediaId}'), + ); + } + + /// Delegates to the appropriate state widget based on loading/error/data. + Widget _buildBody(BuildContext context) { + // Full-screen spinner only on the very first load (no data yet). + if (_isLoading && _media == null) { + return const Center( + key: Key('media_detail_loading'), + child: CircularProgressIndicator(), + ); + } + + if (_error != null) { + return _ErrorView( + message: _error!, + onRetry: _load, + ); + } + + if (_media == null) { + // Should not happen in normal flow, but guard defensively. + return const SizedBox.shrink(); + } + + return RefreshIndicator( + onRefresh: _load, + child: _MediaDetailContent( + media: _media!, + thumbnailUrl: ref.read(apiClientProvider).thumbnailUrl(_media!.id), + onFavoriteToggle: _toggleFavorite, + onPlay: _play, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// _MediaDetailContent +// --------------------------------------------------------------------------- + +/// Scrollable body of the media detail screen. +/// +/// Extracted from [_MediaDetailScreenState] so the state class stays concise +/// and this widget is independently testable. All callbacks are injected so +/// this widget has no direct dependency on providers or navigation +/// (Dependency Inversion, Single Responsibility). +class _MediaDetailContent extends StatelessWidget { + const _MediaDetailContent({ + required this.media, + required this.thumbnailUrl, + required this.onFavoriteToggle, + required this.onPlay, + }); + + final Media media; + + /// Pre-computed thumbnail URL so this widget stays provider-free. + final String thumbnailUrl; + + /// Called when the favourite icon button is tapped. + final VoidCallback onFavoriteToggle; + + /// Called when the play button is tapped. + final VoidCallback onPlay; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Full-width thumbnail/cover image. + _ThumbnailBanner(thumbnailUrl: thumbnailUrl, type: media.type), + + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title + favourite toggle on the same row. + _TitleRow( + title: media.fileName, + isFavorite: media.favorite, + onFavoriteToggle: onFavoriteToggle, + ), + + const SizedBox(height: 8), + + // Codec · resolution · duration · file size. + _MetadataRow(media: media), + + // Tag chips (hidden when no tags). + if (media.tags.isNotEmpty) ...[ + const SizedBox(height: 12), + _TagChips(tags: media.tags), + ], + + const SizedBox(height: 24), + ], + ), + ), + + // Play button anchored at the bottom of the scrollable area. + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _PlayButton(type: media.type, onPlay: onPlay), + ), + + const SizedBox(height: 24), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// _ThumbnailBanner +// --------------------------------------------------------------------------- + +/// Full-width hero image at the top of the detail screen. +/// +/// Falls back to an icon placeholder when [thumbnailUrl] is empty or the +/// network request fails — mirrors the card thumbnail pattern from +/// [MediaGridScreen] for visual consistency. +class _ThumbnailBanner extends StatelessWidget { + const _ThumbnailBanner({required this.thumbnailUrl, required this.type}); + + final String thumbnailUrl; + + /// Media type string used to choose the placeholder icon. + final String type; + + @override + Widget build(BuildContext context) { + return AspectRatio( + // 16:9 for video; square-ish (4:3) for audio/other for visual variety. + aspectRatio: type == 'video' ? 16 / 9 : 4 / 3, + child: thumbnailUrl.isEmpty + ? _placeholder(context) + : CachedNetworkImage( + key: const Key('media_detail_thumbnail'), + imageUrl: thumbnailUrl, + fit: BoxFit.cover, + placeholder: (_, __) => + const Center(child: CircularProgressIndicator()), + errorWidget: (_, __, ___) => _placeholder(context), + ), + ); + } + + /// Colored box with a type-appropriate icon when no thumbnail is available. + Widget _placeholder(BuildContext context) { + final icon = type == 'video' + ? Icons.videocam_outlined + : type == 'audio' + ? Icons.headphones_outlined + : Icons.image_outlined; + + return ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Icon( + icon, + size: 72, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// _TitleRow +// --------------------------------------------------------------------------- + +/// Row containing the media title and a favourite toggle icon button. +/// +/// The favourite icon is filled when [isFavorite] is true, outlined otherwise. +/// Tapping calls [onFavoriteToggle] — the actual API call and state update are +/// handled by the parent state class. +class _TitleRow extends StatelessWidget { + const _TitleRow({ + required this.title, + required this.isFavorite, + required this.onFavoriteToggle, + }); + + final String title; + final bool isFavorite; + final VoidCallback onFavoriteToggle; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + title, + key: const Key('media_detail_title'), + style: Theme.of(context).textTheme.titleLarge, + ), + ), + IconButton( + key: const Key('media_detail_favorite'), + icon: Icon( + isFavorite ? Icons.favorite : Icons.favorite_border, + color: isFavorite + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + tooltip: isFavorite ? 'Remove from favourites' : 'Add to favourites', + onPressed: onFavoriteToggle, + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// _MetadataRow +// --------------------------------------------------------------------------- + +/// Horizontal row of codec · resolution · duration · file-size chips. +/// +/// Renders each non-empty value as a compact text badge separated by a +/// centred dot divider. Empty or zero values are omitted to avoid noise +/// (e.g. audio items have no meaningful resolution). +class _MetadataRow extends StatelessWidget { + const _MetadataRow({required this.media}); + + final Media media; + + @override + Widget build(BuildContext context) { + final parts = _buildParts(); + if (parts.isEmpty) return const SizedBox.shrink(); + + return Wrap( + key: const Key('media_detail_metadata'), + spacing: 4, + runSpacing: 4, + children: [ + for (int i = 0; i < parts.length; i++) ...[ + if (i > 0) + Text( + '·', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + Text( + parts[i], + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ); + } + + /// Collects non-empty metadata strings to display. + List<String> _buildParts() { + final parts = <String>[]; + if (media.codec.isNotEmpty) parts.add(media.codec); + if (media.resolution.isNotEmpty) parts.add(media.resolution); + if (media.duration > 0) parts.add(_formatDuration(media.duration)); + if (media.fileSizeBytes > 0) parts.add(_formatFileSize(media.fileSizeBytes)); + return parts; + } + + /// Formats [seconds] as `h:mm:ss` or `m:ss`. + static String _formatDuration(double seconds) { + final total = seconds.truncate(); + final h = total ~/ 3600; + final m = (total % 3600) ~/ 60; + final s = total % 60; + if (h > 0) { + return '$h:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; + } + return '$m:${s.toString().padLeft(2, '0')}'; + } + + /// Formats [bytes] as a human-readable size string (KB, MB, GB). + static String _formatFileSize(int bytes) { + if (bytes >= 1073741824) { + return '${(bytes / 1073741824).toStringAsFixed(1)} GB'; + } + if (bytes >= 1048576) { + return '${(bytes / 1048576).toStringAsFixed(1)} MB'; + } + return '${(bytes / 1024).toStringAsFixed(0)} KB'; + } +} + +// --------------------------------------------------------------------------- +// _TagChips +// --------------------------------------------------------------------------- + +/// Horizontally wrapping row of tag chips. +/// +/// Uses [Chip] (non-interactive, display-only) rather than [FilterChip] +/// because the detail screen does not filter — it merely shows what tags +/// are attached to the item. +class _TagChips extends StatelessWidget { + const _TagChips({required this.tags}); + + final List<String> tags; + + @override + Widget build(BuildContext context) { + return Wrap( + key: const Key('media_detail_tags'), + spacing: 8, + runSpacing: 4, + children: [ + for (final tag in tags) + Chip( + label: Text(tag), + labelStyle: Theme.of(context).textTheme.labelSmall, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// _PlayButton +// --------------------------------------------------------------------------- + +/// Full-width play button at the bottom of the detail screen. +/// +/// Shows a video or audio icon depending on [type]. Calls [onPlay] when +/// tapped; routing to the correct player is the parent's responsibility +/// (Single Responsibility: this widget only concerns itself with the button +/// appearance and callback delegation). +class _PlayButton extends StatelessWidget { + const _PlayButton({required this.type, required this.onPlay}); + + final String type; + final VoidCallback onPlay; + + @override + Widget build(BuildContext context) { + final isVideo = type == 'video'; + return FilledButton.icon( + key: const Key('media_detail_play'), + onPressed: onPlay, + icon: Icon(isVideo ? Icons.play_circle_outline : Icons.headphones), + label: Text(isVideo ? 'Play Video' : 'Play Audio'), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// _ErrorView +// --------------------------------------------------------------------------- + +/// Full-screen error view with a retry button. +/// +/// Shown when [getMedia] throws. The [message] comes from +/// [mediaDetailErrorMessage], which maps exceptions to human-readable strings. +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('media_detail_error'), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + key: const Key('media_detail_retry'), + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), ); } } diff --git a/player-android/lib/utils/error_mappers.dart b/player-android/lib/utils/error_mappers.dart index 7353928..e90b9c2 100644 --- a/player-android/lib/utils/error_mappers.dart +++ b/player-android/lib/utils/error_mappers.dart @@ -91,3 +91,21 @@ String mediaErrorMessage(Object error) { } return 'Unexpected error. Please try again.'; } + +/// Maps any thrown object from [PlayerApiClient.getMedia] to a UI string. +/// +/// Adds a 404-specific message ("Media not found") on top of the generic +/// connection-error mapping so the detail screen can distinguish between a +/// missing item and a network/server failure (Open-Closed: isolated from the +/// list-media helper so either can evolve independently). +String mediaDetailErrorMessage(Object error) { + if (error is DioException) { + // Surface a friendly "not found" message for 404 so users know the item + // no longer exists rather than seeing a generic server-error message. + if (error.response?.statusCode == 404) { + return 'Media not found. It may have been deleted.'; + } + return dioConnectionErrorMessage(error); + } + return 'Unexpected error. Please try again.'; +} diff --git a/player-android/test/screens/media_detail_screen_test.dart b/player-android/test/screens/media_detail_screen_test.dart new file mode 100644 index 0000000..87ece3c --- /dev/null +++ b/player-android/test/screens/media_detail_screen_test.dart @@ -0,0 +1,516 @@ +// Widget tests for MediaDetailScreen (media_detail_screen.dart). +// +// Tests cover: +// 1. Shows a loading indicator while getMedia is in flight. +// 2. Renders title, metadata row, tag chips, and thumbnail after a +// successful load. +// 3. Play button routes to /video/:id for a video item. +// 4. Play button routes to /audio/:id for an audio item. +// 5. Favourite toggle button flips the icon and calls toggleFavorite. +// 6. Shows an error view when getMedia throws a DioException. +// 7. Retry button triggers a fresh getMedia call. +// 8. 404 error is mapped to the "not found" message. +// +// Riverpod providers are overridden with fakes so tests run without a real +// server or OS keychain. +// +// Run with: flutter test test/screens/media_detail_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/media_detail_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 [MediaDetailScreen] tests. +/// +/// Only [getMedia], [toggleFavorite], and [thumbnailUrl] are implemented; +/// all other methods remain [UnimplementedError]. +class _FakeApiClient extends PlayerApiClient { + _FakeApiClient() : super(dio: Dio()); + + /// When non-null, [getMedia] returns this value. + Media? mediaResult; + + /// When non-null, [getMedia] throws this instead of returning. + Object? mediaError; + + /// When non-null, [toggleFavorite] returns this bool. + bool? toggleResult; + + /// When non-null, [toggleFavorite] throws this instead of returning. + Object? toggleError; + + /// Records how many times [getMedia] was called. + int getMediaCallCount = 0; + + /// Records how many times [toggleFavorite] was called. + int toggleCallCount = 0; + + @override + Future<Media> getMedia(int mediaId) async { + getMediaCallCount++; + if (mediaError != null) throw mediaError!; + return mediaResult!; + } + + @override + Future<bool> toggleFavorite(int mediaId) async { + toggleCallCount++; + if (toggleError != null) throw toggleError!; + return toggleResult!; + } + + /// Returns an empty string so [_ThumbnailBanner] shows the static + /// placeholder instead of making a network request — keeps tests hermetic. + @override + String thumbnailUrl(int mediaId) => ''; +} + +/// [PlayerApiClient] stub that delays [getMedia] 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<Media>(); + + /// Resolves the pending [getMedia] call with [media]. + void complete(Media media) => _completer.complete(media); + + @override + Future<Media> getMedia(int mediaId) => _completer.future; + + @override + String thumbnailUrl(int mediaId) => ''; +} + +// --------------------------------------------------------------------------- +// Sample data +// --------------------------------------------------------------------------- + +/// A sample video item used across tests. +const _kVideo = Media( + id: 42, + setId: 10, + relPath: 'movies/action/hero.mp4', + fileName: 'hero.mp4', + absPath: '/media/movies/action/hero.mp4', + type: 'video', + duration: 7320.0, // 2h 2m + codec: 'h264/aac', + resolution: '1920x1080', + bitrate: 4500, + fileSizeBytes: 1073741824, // 1 GiB + width: 1920, + height: 1080, + thumbnailPath: '/media/.thumbs/hero.jpg', + playCount: 3, + favorite: false, + tags: ['action', 'english'], +); + +/// A sample audio item used across tests. +const _kAudio = Media( + id: 7, + setId: 5, + relPath: 'music/song.mp3', + fileName: 'song.mp3', + absPath: '/media/music/song.mp3', + type: 'audio', + duration: 210.0, // 3m 30s + codec: 'mp3', + resolution: '', + bitrate: 320, + fileSizeBytes: 8388608, + width: 0, + height: 0, + thumbnailPath: '', + playCount: 12, + favorite: true, + tags: [], +); + +// --------------------------------------------------------------------------- +// Helper: pump MediaDetailScreen inside a minimal ProviderScope + GoRouter +// --------------------------------------------------------------------------- + +/// Key used by the navigation-destination stub route. +const _kDestinationKey = Key('nav_destination'); + +/// Builds a [GoRouter] with [MediaDetailScreen] at `/media/:id` and stub +/// routes at `/video/:mediaId` and `/audio/:mediaId` for navigation tests. +GoRouter _buildRouter(PlayerApiClient fakeClient, String mediaId) { + return GoRouter( + initialLocation: '/media/$mediaId', + routes: [ + GoRoute( + path: '/media/:id', + builder: (context, state) => + MediaDetailScreen(mediaId: state.pathParameters['id']!), + ), + GoRoute( + path: '/video/:mediaId', + builder: (context, state) => Scaffold( + body: Text( + 'Video ${state.pathParameters['mediaId']}', + key: _kDestinationKey, + ), + ), + ), + GoRoute( + path: '/audio/:mediaId', + builder: (context, state) => Scaffold( + body: Text( + 'Audio ${state.pathParameters['mediaId']}', + key: _kDestinationKey, + ), + ), + ), + ], + ); +} + +/// Pumps [MediaDetailScreen] for [mediaId] inside a [ProviderScope] with +/// overridden providers, backed by a [GoRouter] for navigation tests. +Future<void> _pumpScreen( + WidgetTester tester, + PlayerApiClient fakeClient, { + String mediaId = '42', +}) async { + final router = _buildRouter(fakeClient, mediaId); + 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 while getMedia is in flight', + (tester) async { + final fakeClient = _DelayedFakeApiClient(); + + await _pumpScreen(tester, fakeClient); + + // Pump one frame: initState fires, addPostFrameCallback enqueues the + // load, and the Future has not resolved yet. + await tester.pump(); + + expect( + find.byKey(const Key('media_detail_loading')), + findsOneWidget, + ); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // Resolve to prevent "pending async work" warnings. + fakeClient.complete(_kVideo); + await tester.pumpAndSettle(); + }); + }); + + // -------------------------------------------------------------------------- + // Successful render + // -------------------------------------------------------------------------- + + group('successful render', () { + testWidgets('renders title after a successful load', (tester) async { + final fakeClient = _FakeApiClient()..mediaResult = _kVideo; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('media_detail_title')), findsOneWidget); + expect(find.text('hero.mp4'), findsWidgets); + }); + + testWidgets('renders metadata row with codec, resolution, duration, size', + (tester) async { + final fakeClient = _FakeApiClient()..mediaResult = _kVideo; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('media_detail_metadata')), findsOneWidget); + // Codec. + expect(find.textContaining('h264/aac'), findsOneWidget); + // Resolution. + expect(find.textContaining('1920x1080'), findsOneWidget); + // Duration: 7320 s = 2h 2m 0s → "2:02:00". + expect(find.textContaining('2:02:00'), findsOneWidget); + // File size: 1 GiB → "1.0 GB". + expect(find.textContaining('1.0 GB'), findsOneWidget); + }); + + testWidgets('renders tag chips when tags are present', (tester) async { + final fakeClient = _FakeApiClient()..mediaResult = _kVideo; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('media_detail_tags')), findsOneWidget); + expect(find.text('action'), findsOneWidget); + expect(find.text('english'), findsOneWidget); + }); + + testWidgets('hides tag chips row when tags list is empty', (tester) async { + final fakeClient = _FakeApiClient()..mediaResult = _kAudio; + + await _pumpScreen(tester, fakeClient, mediaId: '7'); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('media_detail_tags')), findsNothing); + }); + + testWidgets('renders play button', (tester) async { + final fakeClient = _FakeApiClient()..mediaResult = _kVideo; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('media_detail_play')), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // Play button routing + // -------------------------------------------------------------------------- + + group('play button routing', () { + testWidgets('tapping play on a video item routes to /video/:id', + (tester) async { + final fakeClient = _FakeApiClient()..mediaResult = _kVideo; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // The play button may be below the visible area in the test viewport; + // scroll it into view before tapping. + await tester.ensureVisible(find.byKey(const Key('media_detail_play'))); + await tester.tap(find.byKey(const Key('media_detail_play'))); + await tester.pumpAndSettle(); + + // The stub route at /video/:mediaId must be visible. |
