fix(player): 修正封面比例与首帧切换闪烁
This commit is contained in:
1 parent
04f034817c
commit
a3a79f7068
5 files changed
+144
-3
No files matched your search
@@ -612,6 +612,7 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
handoff: handoffPort?.controller,
|
handoff: handoffPort?.controller,
|
||||||
videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl,
|
videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl,
|
||||||
theater: theater,
|
theater: theater,
|
||||||
|
holdOpeningUntilFirstFrame: logic.session.userWantsPlay,
|
||||||
openingFrame: playerOpeningFrame(
|
openingFrame: playerOpeningFrame(
|
||||||
episode.thumbnailsUrl,
|
episode.thumbnailsUrl,
|
||||||
posterUrl: drama.posterUrl,
|
posterUrl: drama.posterUrl,
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import '../../../core/theme/app_colors.dart';
|
|||||||
import '../../../core/widgets/remote_image.dart';
|
import '../../../core/widgets/remote_image.dart';
|
||||||
import 'scrub_preview_card.dart';
|
import 'scrub_preview_card.dart';
|
||||||
|
|
||||||
|
const double _posterAspectRatio = 3 / 4;
|
||||||
|
const double _posterBlurSigma = 18;
|
||||||
|
|
||||||
/// 封面立即可见,雪碧图首帧解码完成后再覆盖封面。
|
/// 封面立即可见,雪碧图首帧解码完成后再覆盖封面。
|
||||||
Widget? playerOpeningFrame(String? thumbnailsUrl, {String? posterUrl}) {
|
Widget? playerOpeningFrame(String? thumbnailsUrl, {String? posterUrl}) {
|
||||||
final url = thumbnailsUrl?.trim() ?? '';
|
final url = thumbnailsUrl?.trim() ?? '';
|
||||||
@@ -82,12 +85,34 @@ class _PlayerOpeningFrameState extends State<PlayerOpeningFrame> {
|
|||||||
children: [
|
children: [
|
||||||
const ColoredBox(color: AppColors.mediaBlack),
|
const ColoredBox(color: AppColors.mediaBlack),
|
||||||
if (posterUrl != null)
|
if (posterUrl != null)
|
||||||
RemoteImage(
|
Positioned.fill(
|
||||||
|
child: ImageFiltered(
|
||||||
|
key: const Key('player-opening-poster-blur'),
|
||||||
|
imageFilter: ui.ImageFilter.blur(
|
||||||
|
sigmaX: _posterBlurSigma,
|
||||||
|
sigmaY: _posterBlurSigma,
|
||||||
|
),
|
||||||
|
child: RemoteImage(
|
||||||
|
posterUrl,
|
||||||
|
key: const Key('player-opening-poster-backdrop'),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fallbackColor: AppColors.mediaBlack,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (posterUrl != null)
|
||||||
|
Center(
|
||||||
|
child: AspectRatio(
|
||||||
|
key: const Key('player-opening-poster-stage'),
|
||||||
|
aspectRatio: _posterAspectRatio,
|
||||||
|
child: RemoteImage(
|
||||||
posterUrl,
|
posterUrl,
|
||||||
key: const Key('player-opening-poster'),
|
key: const Key('player-opening-poster'),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
fallbackColor: AppColors.mediaBlack,
|
fallbackColor: AppColors.mediaBlack,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (frame != null)
|
if (frame != null)
|
||||||
SpriteCropPreview(
|
SpriteCropPreview(
|
||||||
key: const Key('player-opening-sprite'),
|
key: const Key('player-opening-sprite'),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class PlayerVideoSurface extends StatefulWidget {
|
|||||||
required this.theater,
|
required this.theater,
|
||||||
this.handoff,
|
this.handoff,
|
||||||
this.openingFrame,
|
this.openingFrame,
|
||||||
|
this.holdOpeningUntilFirstFrame = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final VideoPlayerController? controller;
|
final VideoPlayerController? controller;
|
||||||
@@ -26,6 +27,9 @@ class PlayerVideoSurface extends StatefulWidget {
|
|||||||
|
|
||||||
/// 解码出第一帧之前垫上的视频首帧,没有则为空。
|
/// 解码出第一帧之前垫上的视频首帧,没有则为空。
|
||||||
final Widget? openingFrame;
|
final Widget? openingFrame;
|
||||||
|
|
||||||
|
/// 播放中的新片源在位置首次前进前继续显示封面,避免混入未出帧纹理。
|
||||||
|
final bool holdOpeningUntilFirstFrame;
|
||||||
final String videoUrl;
|
final String videoUrl;
|
||||||
final bool theater;
|
final bool theater;
|
||||||
|
|
||||||
@@ -35,11 +39,21 @@ class PlayerVideoSurface extends StatefulWidget {
|
|||||||
|
|
||||||
class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
|
class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
|
||||||
String? _lastDebugState;
|
String? _lastDebugState;
|
||||||
|
String? _lastLayerState;
|
||||||
|
VideoPlayerController? _firstFrameController;
|
||||||
|
bool _playbackStarted = false;
|
||||||
|
bool _firstFrameSeen = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant PlayerVideoSurface oldWidget) {
|
void didUpdateWidget(covariant PlayerVideoSurface oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.controller != widget.controller) _lastDebugState = null;
|
if (oldWidget.controller != widget.controller) {
|
||||||
|
_lastDebugState = null;
|
||||||
|
_lastLayerState = null;
|
||||||
|
_firstFrameController = null;
|
||||||
|
_playbackStarted = false;
|
||||||
|
_firstFrameSeen = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -55,8 +69,15 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
|
|||||||
Widget _frame(VideoPlayerValue? value) {
|
Widget _frame(VideoPlayerValue? value) {
|
||||||
final controller = widget.controller;
|
final controller = widget.controller;
|
||||||
final handoff = _liveHandoff(controller);
|
final handoff = _liveHandoff(controller);
|
||||||
final reveal = _shouldReveal(value, handoff != null);
|
final firstFrameReady = _trackFrameState(controller, value);
|
||||||
|
final reveal = _shouldReveal(value, handoff != null) &&
|
||||||
|
(!widget.holdOpeningUntilFirstFrame ||
|
||||||
|
firstFrameReady ||
|
||||||
|
_playbackStarted &&
|
||||||
|
value?.isPlaying == false &&
|
||||||
|
value?.isBuffering != true);
|
||||||
_logStateChange(value);
|
_logStateChange(value);
|
||||||
|
_logLayerState(value, handoff, reveal, firstFrameReady);
|
||||||
return Stack(
|
return Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
@@ -116,6 +137,22 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
|
|||||||
return value.size.width > 0 && value.size.height > 0;
|
return value.size.width > 0 && value.size.height > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _trackFrameState(
|
||||||
|
VideoPlayerController? controller,
|
||||||
|
VideoPlayerValue? value,
|
||||||
|
) {
|
||||||
|
if (!identical(_firstFrameController, controller)) {
|
||||||
|
_firstFrameController = controller;
|
||||||
|
_playbackStarted = false;
|
||||||
|
_firstFrameSeen = false;
|
||||||
|
}
|
||||||
|
if (value == null) return false;
|
||||||
|
_playbackStarted = _playbackStarted || value.isPlaying;
|
||||||
|
_firstFrameSeen =
|
||||||
|
_firstFrameSeen || value.position > Duration.zero || value.isCompleted;
|
||||||
|
return _firstFrameSeen;
|
||||||
|
}
|
||||||
|
|
||||||
/// 只画会话仍持有的旧画面,控制器一旦被换掉就不再引用。
|
/// 只画会话仍持有的旧画面,控制器一旦被换掉就不再引用。
|
||||||
VideoPlayerController? _liveHandoff(VideoPlayerController? controller) {
|
VideoPlayerController? _liveHandoff(VideoPlayerController? controller) {
|
||||||
final handoff = widget.handoff;
|
final handoff = widget.handoff;
|
||||||
@@ -138,6 +175,33 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _logLayerState(
|
||||||
|
VideoPlayerValue? value,
|
||||||
|
VideoPlayerController? handoff,
|
||||||
|
bool reveal,
|
||||||
|
bool firstFrameReady,
|
||||||
|
) {
|
||||||
|
if (!kDebugMode) return;
|
||||||
|
final reason = handoff != null
|
||||||
|
? 'handoff'
|
||||||
|
: !_shouldReveal(value, handoff != null)
|
||||||
|
? 'uninitialized'
|
||||||
|
: !firstFrameReady && widget.holdOpeningUntilFirstFrame
|
||||||
|
? 'awaiting-first-frame'
|
||||||
|
: 'reveal';
|
||||||
|
final signature =
|
||||||
|
'opening=${widget.openingFrame == null ? 0 : reveal ? 0 : 1} '
|
||||||
|
'video=${reveal ? 1 : 0} handoff=${handoff == null ? 0 : 1} '
|
||||||
|
'init=${value?.isInitialized == true ? 1 : 0} '
|
||||||
|
'frame=${firstFrameReady ? 1 : 0} '
|
||||||
|
'playing=${value?.isPlaying == true ? 1 : 0} '
|
||||||
|
'buffering=${value?.isBuffering == true ? 1 : 0} '
|
||||||
|
'reason=$reason';
|
||||||
|
if (signature == _lastLayerState) return;
|
||||||
|
_lastLayerState = signature;
|
||||||
|
debugPrint('[player-layer] $signature');
|
||||||
|
}
|
||||||
|
|
||||||
String _debugStatus(VideoPlayerValue? value) {
|
String _debugStatus(VideoPlayerValue? value) {
|
||||||
if (value == null) return 'loading';
|
if (value == null) return 'loading';
|
||||||
if (value.hasError) {
|
if (value.hasError) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'dart:ui' as ui;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:fruitshorts/core/data/services/sprite_thumbnail_asset_loader.dart';
|
import 'package:fruitshorts/core/data/services/sprite_thumbnail_asset_loader.dart';
|
||||||
|
import 'package:fruitshorts/core/widgets/remote_image.dart';
|
||||||
import 'package:fruitshorts/pages/player/widgets/player_opening_frame.dart';
|
import 'package:fruitshorts/pages/player/widgets/player_opening_frame.dart';
|
||||||
|
|
||||||
const String _spriteVtt = '''
|
const String _spriteVtt = '''
|
||||||
@@ -82,6 +83,40 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(find.byKey(const Key('player-opening-poster')), findsOneWidget);
|
expect(find.byKey(const Key('player-opening-poster')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('player-opening-poster-backdrop')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_constrain_poster_inside_native_vertical_frame', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: PlayerOpeningFrame(
|
||||||
|
posterUrl: 'https://cdn.test/poster.jpg',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final stage = tester.widget<AspectRatio>(
|
||||||
|
find.byKey(const Key('player-opening-poster-stage')),
|
||||||
|
);
|
||||||
|
final poster = tester.widget<RemoteImage>(
|
||||||
|
find.byKey(const Key('player-opening-poster')),
|
||||||
|
);
|
||||||
|
final backdrop = tester.widget<RemoteImage>(
|
||||||
|
find.byKey(const Key('player-opening-poster-backdrop')),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stage.aspectRatio, closeTo(3 / 4, 0.001));
|
||||||
|
expect(poster.fit, BoxFit.cover);
|
||||||
|
expect(backdrop.fit, BoxFit.cover);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('player-opening-poster-blur')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_reuse_decoded_sprite_when_parent_rebuilds', (
|
testWidgets('should_reuse_decoded_sprite_when_parent_rebuilds', (
|
||||||
|
|||||||
@@ -55,9 +55,23 @@ void main() {
|
|||||||
final controller = await _pumpSurface(
|
final controller = await _pumpSurface(
|
||||||
tester,
|
tester,
|
||||||
openingFrame: const ColoredBox(color: Colors.red),
|
openingFrame: const ColoredBox(color: Colors.red),
|
||||||
|
holdOpeningUntilFirstFrame: true,
|
||||||
);
|
);
|
||||||
expect(_opacity(tester), 0);
|
expect(_opacity(tester), 0);
|
||||||
|
|
||||||
|
controller.value = const VideoPlayerValue(
|
||||||
|
duration: Duration(minutes: 1),
|
||||||
|
size: Size(1280, 720),
|
||||||
|
position: Duration.zero,
|
||||||
|
isInitialized: true,
|
||||||
|
isPlaying: true,
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(_opacity(tester), 0);
|
||||||
|
expect(_openingOpacity(tester), 1);
|
||||||
|
expect(find.byKey(const Key('player-opening-frame')), findsOneWidget);
|
||||||
|
|
||||||
controller.value = const VideoPlayerValue(
|
controller.value = const VideoPlayerValue(
|
||||||
duration: Duration(minutes: 1),
|
duration: Duration(minutes: 1),
|
||||||
size: Size(1280, 720),
|
size: Size(1280, 720),
|
||||||
@@ -153,6 +167,7 @@ Future<VideoPlayerController> _pumpSurface(
|
|||||||
WidgetTester tester, {
|
WidgetTester tester, {
|
||||||
VideoPlayerValue? value,
|
VideoPlayerValue? value,
|
||||||
Widget? openingFrame,
|
Widget? openingFrame,
|
||||||
|
bool holdOpeningUntilFirstFrame = false,
|
||||||
}) async {
|
}) async {
|
||||||
final controller = VideoPlayerController.networkUrl(
|
final controller = VideoPlayerController.networkUrl(
|
||||||
Uri.parse('http://192.168.31.187:8081/files/video.mp4'),
|
Uri.parse('http://192.168.31.187:8081/files/video.mp4'),
|
||||||
@@ -167,6 +182,7 @@ Future<VideoPlayerController> _pumpSurface(
|
|||||||
videoUrl: controller.dataSource,
|
videoUrl: controller.dataSource,
|
||||||
theater: false,
|
theater: false,
|
||||||
openingFrame: openingFrame,
|
openingFrame: openingFrame,
|
||||||
|
holdOpeningUntilFirstFrame: holdOpeningUntilFirstFrame,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in new issue
Block a user