fix(flutter): 修复 iOS 视频首帧闪屏
This commit is contained in:
1 parent
ca6776580f
commit
6b397015b1
3 files changed
+345
-54
No files matched your search
@@ -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<PlayerPage> 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<PlayerPage> 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<PlayerPage> 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<PlayerVideoSurface> createState() => _PlayerVideoSurfaceState();
|
||||
}
|
||||
|
||||
class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
|
||||
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<VideoPlayerValue>(
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Text>(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<AnimatedOpacity>(
|
||||
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<Text>(find.byKey(const Key('player-debug-video'))).data,
|
||||
'error: No route to host',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<VideoPlayerController> _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<AnimatedOpacity>(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<FadeTransition>(fade).opacity.value;
|
||||
}
|
||||
Reference in new issue
Block a user