From 6b397015b1d606e42f5b0ea7747083b821881843 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 18:29:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(flutter):=20=E4=BF=AE=E5=A4=8D=20iOS=20?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E9=A6=96=E5=B8=A7=E9=97=AA=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 63 +---- .../player/widgets/player_video_surface.dart | 222 ++++++++++++++++++ test/player/player_video_surface_test.dart | 114 +++++++++ 3 files changed, 345 insertions(+), 54 deletions(-) create mode 100644 lib/pages/player/widgets/player_video_surface.dart create mode 100644 test/player/player_video_surface_test.dart diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 9991f03..6082074 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -5,7 +5,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import 'package:video_player/video_player.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/services/favorite_controller.dart'; @@ -20,7 +19,6 @@ import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; -import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import 'comments_auth.dart'; import 'player_binding.dart'; @@ -34,6 +32,7 @@ import 'widgets/player_overlay.dart'; import 'widgets/player_overlay_models.dart'; import 'widgets/player_speed_sheet.dart'; import 'widgets/player_unlock_sheet.dart'; +import 'widgets/player_video_surface.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; @@ -278,24 +277,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { final videoPort = port is VideoPlayerPort ? port : null; final ready = videoPort?.controller.value.isInitialized ?? false; final theater = logic.pip || logic.pipPreparing; - Widget? video; - if (ready) { - video = GestureDetector( - onTap: () => - logic.session.setUserWantsPlay(!logic.session.userWantsPlay), - child: FittedBox( - fit: theater ? BoxFit.contain : BoxFit.cover, - child: SizedBox( - width: videoPort!.controller.value.size.width, - height: videoPort.controller.value.size.height, - child: VideoPlayer( - videoPort.controller, - key: ValueKey(videoPort.controller), - ), - ), - ), - ); - } return Stack( fit: StackFit.expand, @@ -305,8 +286,14 @@ class _PlayerPageState extends State with WidgetsBindingObserver { key: Key('home-feed-black-background'), color: Colors.black, ), - if (!ready) _PlayerFallbackBackground(posterUrl: drama.posterUrl), - ?video, + PlayerVideoSurface( + controller: videoPort?.controller, + posterUrl: drama.posterUrl, + videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, + theater: theater, + onTap: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + ), if (widget.homeMode && _chromeVisible && !logic.chromeHidden) Positioned.fill( child: GestureDetector( @@ -563,35 +550,3 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ); } } - -class _PlayerFallbackBackground extends StatelessWidget { - const _PlayerFallbackBackground({required this.posterUrl}); - - final String posterUrl; - - @override - Widget build(BuildContext context) { - if (posterUrl.trim().isEmpty) { - return const ColoredBox( - key: Key('player-fallback-background'), - color: Colors.black, - ); - } - return Stack( - key: const Key('player-fallback-background'), - fit: StackFit.expand, - children: [ - RemoteImage( - posterUrl, - key: const Key('player-fallback-cover'), - fit: BoxFit.cover, - fallbackColor: Colors.black, - ), - const ColoredBox( - key: Key('player-fallback-scrim'), - color: Color(0x4D000000), - ), - ], - ); - } -} diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart new file mode 100644 index 0000000..467286c --- /dev/null +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -0,0 +1,222 @@ +// Author: fruit.com.cn + +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 _debugRight = 8; +const double _debugBottom = 8; +const double _debugFontSize = 11; +const double _errorHorizontal = 32; +const int _errorMaxLines = 3; + +/// 封面底层与视频层共用同一控制器,视频首帧就绪后再淡入。 +class PlayerVideoSurface extends StatefulWidget { + const PlayerVideoSurface({ + super.key, + required this.controller, + required this.posterUrl, + required this.videoUrl, + required this.theater, + required this.onTap, + }); + + final VideoPlayerController? controller; + final String posterUrl; + final String videoUrl; + final bool theater; + final VoidCallback onTap; + + @override + State createState() => _PlayerVideoSurfaceState(); +} + +class _PlayerVideoSurfaceState extends State { + String? _lastDebugState; + + @override + void didUpdateWidget(covariant PlayerVideoSurface oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) _lastDebugState = null; + } + + @override + Widget build(BuildContext context) { + final controller = widget.controller; + if (controller == null) return _frame(null); + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) => _frame(value), + ); + } + + Widget _frame(VideoPlayerValue? value) { + final controller = widget.controller; + final reveal = _shouldReveal(value); + _logStateChange(value); + return Stack( + fit: StackFit.expand, + children: [ + _FallbackBackground(posterUrl: widget.posterUrl), + AnimatedOpacity( + key: const Key('player-video-opacity'), + opacity: reveal ? 1 : 0, + duration: _videoFadeDuration, + curve: Curves.easeOut, + child: reveal && controller != null + ? _VideoLayer( + controller: controller, + value: value!, + theater: widget.theater, + onTap: widget.onTap, + ) + : const SizedBox.expand(), + ), + if (kDebugMode) + Positioned( + right: _debugRight, + bottom: _debugBottom, + child: DecoratedBox( + decoration: const BoxDecoration(color: Color(0x99000000)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + child: Text( + _debugStatus(value), + key: const Key('player-debug-video'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white70, + fontSize: _debugFontSize, + ), + ), + ), + ), + ), + if (kDebugMode && value?.hasError == true) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: _errorHorizontal), + child: Text( + value?.errorDescription ?? 'unknown video error', + key: const Key('player-video-error'), + maxLines: _errorMaxLines, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70, fontSize: 13), + ), + ), + ), + ], + ); + } + + bool _shouldReveal(VideoPlayerValue? value) { + return value != null && + !value.hasError && + value.isInitialized && + value.size.width > 0 && + (value.position > Duration.zero || value.isPlaying); + } + + void _logStateChange(VideoPlayerValue? value) { + if (!kDebugMode) return; + final status = _debugStatus(value); + final signature = + '$status|${value?.isInitialized ?? false}|' + '${value?.errorDescription ?? 'none'}'; + if (signature == _lastDebugState) return; + _lastDebugState = signature; + final source = widget.controller?.dataSource ?? widget.videoUrl; + debugPrint( + '[video] init=${value?.isInitialized ?? false} ' + 'err=${value?.errorDescription ?? 'none'} host=${_host(source)}', + ); + } + + String _debugStatus(VideoPlayerValue? value) { + if (value == null) return 'loading'; + if (value.hasError) { + return 'error: ${value.errorDescription ?? 'unknown'}'; + } + if (value.isBuffering) return 'buffering'; + if (value.isInitialized) return 'initialized'; + return 'loading'; + } + + String _host(String source) { + final uri = Uri.tryParse(source); + if (uri == null || uri.host.isEmpty) return 'unknown'; + final port = uri.hasPort + ? uri.port + : uri.scheme == 'https' + ? 443 + : 80; + return '${uri.host}:$port'; + } +} + +class _VideoLayer extends StatelessWidget { + const _VideoLayer({ + required this.controller, + required this.value, + required this.theater, + required this.onTap, + }); + + final VideoPlayerController controller; + final VideoPlayerValue value; + final bool theater; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: FittedBox( + fit: theater ? BoxFit.contain : BoxFit.cover, + child: SizedBox( + width: value.size.width, + height: value.size.height, + child: VideoPlayer(controller, key: ValueKey(controller)), + ), + ), + ); + } +} + +class _FallbackBackground extends StatelessWidget { + const _FallbackBackground({required this.posterUrl}); + + final String posterUrl; + + @override + Widget build(BuildContext context) { + if (posterUrl.trim().isEmpty) { + return const ColoredBox( + key: Key('player-fallback-background'), + color: Colors.black, + ); + } + return Stack( + key: const Key('player-fallback-background'), + fit: StackFit.expand, + children: [ + RemoteImage( + posterUrl, + key: const Key('player-fallback-cover'), + fit: BoxFit.cover, + fallbackColor: Colors.black, + ), + const ColoredBox( + key: Key('player-fallback-scrim'), + color: Color(0x4D000000), + ), + ], + ); + } +} diff --git a/test/player/player_video_surface_test.dart b/test/player/player_video_surface_test.dart new file mode 100644 index 0000000..3905689 --- /dev/null +++ b/test/player/player_video_surface_test.dart @@ -0,0 +1,114 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:video_player/video_player.dart'; + +import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart'; + +void main() { + testWidgets('should_keep_fallback_visible_before_video_is_ready', ( + tester, + ) async { + await _pumpSurface(tester); + + expect(find.byKey(const Key('player-fallback-background')), findsOneWidget); + expect(find.byKey(const Key('player-fallback-cover')), findsOneWidget); + expect(_opacity(tester), 0); + expect( + tester.widget(find.byKey(const Key('player-debug-video'))).data, + 'loading', + ); + }); + + testWidgets('should_fade_video_in_within_200ms_after_first_frame', ( + tester, + ) async { + final controller = await _pumpSurface(tester); + expect(_opacity(tester), 0); + + controller.value = const VideoPlayerValue( + duration: Duration(minutes: 1), + size: Size(1280, 720), + position: Duration(milliseconds: 1), + isInitialized: true, + isPlaying: true, + ); + await tester.pump(); + + expect(_opacity(tester), 1); + expect( + tester + .widget( + find.byKey(const Key('player-video-opacity')), + ) + .duration, + const Duration(milliseconds: 200), + ); + await tester.pump(const Duration(milliseconds: 100)); + final halfway = _fadeValue(tester); + expect(halfway, greaterThan(0)); + expect(halfway, lessThan(1)); + await tester.pump(const Duration(milliseconds: 100)); + expect(_fadeValue(tester), 1); + expect(find.byKey(const Key('player-fallback-background')), findsOneWidget); + }); + + testWidgets('should_show_video_error_above_retained_fallback', ( + tester, + ) async { + await _pumpSurface( + tester, + value: const VideoPlayerValue.erroneous('No route to host'), + ); + + expect(find.byKey(const Key('player-video-error')), findsOneWidget); + expect(find.text('No route to host'), findsOneWidget); + expect(find.byKey(const Key('player-fallback-background')), findsOneWidget); + expect(_opacity(tester), 0); + expect( + tester.widget(find.byKey(const Key('player-debug-video'))).data, + 'error: No route to host', + ); + }); +} + +Future _pumpSurface( + WidgetTester tester, { + VideoPlayerValue? value, +}) async { + final controller = VideoPlayerController.networkUrl( + Uri.parse('http://192.168.31.187:8081/files/video.mp4'), + ); + addTearDown(controller.dispose); + if (value != null) controller.value = value; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PlayerVideoSurface( + controller: controller, + posterUrl: '/files/poster.jpg', + videoUrl: controller.dataSource, + theater: false, + onTap: () {}, + ), + ), + ), + ); + await tester.pump(); + return controller; +} + +double _opacity(WidgetTester tester) { + return tester + .widget(find.byKey(const Key('player-video-opacity'))) + .opacity; +} + +double _fadeValue(WidgetTester tester) { + final fade = find.descendant( + of: find.byKey(const Key('player-video-opacity')), + matching: find.byType(FadeTransition), + ); + return tester.widget(fade).opacity.value; +}