diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 5ed0578..f3a2daa 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -378,6 +378,7 @@ class _PlayerPageState extends State PlayerVideoSurface( controller: videoPort?.controller, videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, + posterUrl: drama.posterUrl, theater: theater, onTap: () => logic.session.setUserWantsPlay(!logic.session.userWantsPlay), @@ -427,7 +428,7 @@ class _PlayerPageState extends State ), ), ), - if (selected && logic.session.loading) + if (selected && logic.session.loading && !widget.homeMode) const Center( child: CircularProgressIndicator( key: Key('player-session-loading'), diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index 1226e3f..3871635 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -4,22 +4,26 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; +import '../../../core/widgets/remote_image.dart'; + const Duration _videoFadeDuration = Duration(milliseconds: 200); const double _errorHorizontal = 32; const int _errorMaxLines = 3; -/// 黑色底层与视频层共用同一控制器,视频首帧就绪后再淡入。 +/// 封面或黑色底层与视频层共用同一控制器,视频首帧就绪后再淡入。 class PlayerVideoSurface extends StatefulWidget { const PlayerVideoSurface({ super.key, required this.controller, required this.videoUrl, + this.posterUrl = '', required this.theater, required this.onTap, }); final VideoPlayerController? controller; final String videoUrl; + final String posterUrl; final bool theater; final VoidCallback onTap; @@ -53,10 +57,17 @@ class _PlayerVideoSurfaceState extends State { return Stack( fit: StackFit.expand, children: [ - const ColoredBox( - key: Key('player-fallback-background'), - color: Colors.black, - ), + if (widget.posterUrl.isEmpty) + const ColoredBox( + key: Key('player-fallback-background'), + color: Colors.black, + ) + else + RemoteImage( + widget.posterUrl, + key: const Key('player-video-poster'), + fit: BoxFit.cover, + ), AnimatedOpacity( key: const Key('player-video-opacity'), opacity: reveal ? 1 : 0, diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 5b04f60..4d28de0 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -7,9 +7,11 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/feed_page.dart'; +import 'package:fruitshorts/core/data/models/player_port.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/pages/home/home_feed_channel.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; @@ -120,6 +122,37 @@ void main() { expect(player.homeMode, isFalse); }); + testWidgets('should_hide_session_loading_only_on_home_feed', (tester) async { + final players = <_PendingPlayer>[]; + await _pumpApp( + tester, + playerFactory: (_) { + final player = _PendingPlayer(); + players.add(player); + return player; + }, + ); + + expect(find.byType(PlayerPage), findsOneWidget); + expect(find.byKey(const Key('player-session-loading')), findsNothing); + + await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byKey(const Key('player-session-loading')), findsOneWidget); + + for (var round = 0; round < 6; round++) { + for (final player in players) { + if (!player.initialized.isCompleted) { + player.initialized.complete(); + } + } + await tester.pump(); + } + await tester.pumpAndSettle(); + }); + testWidgets('should_switch_to_anime_channel', (tester) async { await _pumpApp(tester); @@ -489,6 +522,7 @@ Future _pumpApp( WidgetTester tester, { Size size = const Size(390, 844), FakeCatalogRepository? catalog, + PlayerFactory? playerFactory, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; @@ -497,7 +531,7 @@ Future _pumpApp( final repository = catalog ?? FakeCatalogRepository(); await tester.pumpWidget( LumaDramaApp( - playerFactory: createWidgetPlayer, + playerFactory: playerFactory ?? createWidgetPlayer, catalogRepository: repository, categoryRepository: FakeCategoryRepository(catalog: repository), watchHistoryRepository: InMemoryWatchHistoryRepository(), @@ -542,3 +576,19 @@ class _EmptyAnimeCatalog extends FakeCatalogRepository { return super.fetchFeed(types: types, cursor: cursor, size: size); } } + +class _PendingPlayer implements PlayerPort { + final initialized = Completer(); + + @override + Future initialize() => initialized.future; + + @override + Future play() async {} + + @override + Future pause() async {} + + @override + Future dispose() async {} +} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 8b763c8..4c65c53 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -131,18 +131,12 @@ void main() { expect(player.playing, isTrue); }); - testWidgets('should_keep_video_surface_background_black', (tester) async { + testWidgets('should_show_poster_before_video_first_frame', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); - expect( - tester - .widget( - find.byKey(const Key('player-fallback-background')), - ) - .color, - Colors.black, - ); + expect(find.byKey(const Key('player-video-poster')), findsOneWidget); + expect(find.byKey(const Key('player-fallback-background')), findsNothing); expect(find.byKey(const Key('player-fallback-cover')), findsNothing); expect(find.byKey(const Key('player-fallback-scrim')), findsNothing); }); @@ -471,9 +465,8 @@ void main() { find.byKey(const Key('player-interaction-rail')), ); expect(find.byKey(const Key('player-background')), findsNothing); - final fallback = find.byKey(const Key('player-fallback-background')); - expect(fallback, findsOneWidget); - expect(tester.widget(fallback).color, Colors.black); + expect(find.byKey(const Key('player-video-poster')), findsOneWidget); + expect(find.byKey(const Key('player-fallback-background')), findsNothing); expect(find.byKey(const Key('player-fallback-cover')), findsNothing); expect(find.byKey(const Key('player-fallback-scrim')), findsNothing); expect( diff --git a/test/player/player_video_surface_test.dart b/test/player/player_video_surface_test.dart index 7ab4f3c..7c652c5 100644 --- a/test/player/player_video_surface_test.dart +++ b/test/player/player_video_surface_test.dart @@ -7,6 +7,24 @@ import 'package:video_player/video_player.dart'; import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart'; void main() { + testWidgets('should_show_poster_when_controller_is_missing', (tester) async { + await _pumpBareSurface(tester, posterUrl: '/files/poster.png'); + + expect(find.byKey(const Key('player-video-poster')), findsOneWidget); + expect(find.byKey(const Key('player-fallback-background')), findsNothing); + expect(_opacity(tester), 0); + }); + + testWidgets('should_keep_black_fallback_when_poster_is_empty', ( + tester, + ) async { + await _pumpBareSurface(tester); + + expect(find.byKey(const Key('player-video-poster')), findsNothing); + expect(_fallbackColor(tester), Colors.black); + expect(_opacity(tester), 0); + }); + testWidgets('should_keep_fallback_visible_before_video_is_ready', ( tester, ) async { @@ -96,6 +114,25 @@ Future _pumpSurface( return controller; } +Future _pumpBareSurface( + WidgetTester tester, { + String posterUrl = '', +}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PlayerVideoSurface( + controller: null, + videoUrl: '', + posterUrl: posterUrl, + theater: false, + onTap: () {}, + ), + ), + ), + ); +} + Color _fallbackColor(WidgetTester tester) { return tester .widget(find.byKey(const Key('player-fallback-background')))