fix(player): 修复切换频道时复用已释放的视频控制器
This commit is contained in:
1 parent
45a53180d9
commit
f3ec12e3c3
6 files changed
+229
-6
No files matched your search
@@ -123,6 +123,38 @@ void main() {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test(
|
||||
'should_detach_retiring_player_before_disposal_finishes',
|
||||
() async {
|
||||
final disposeStarted = Completer<void>();
|
||||
final disposeGate = Completer<void>();
|
||||
final factory = FakeFactory(
|
||||
firstDisposeStarted: disposeStarted,
|
||||
firstDisposeGate: disposeGate,
|
||||
);
|
||||
final session = PlaybackSession(
|
||||
episodes: episodes,
|
||||
factory: factory.create,
|
||||
canPlay: (episode) => episode.isFree,
|
||||
);
|
||||
await session.select(0);
|
||||
final retiring = factory.created.single;
|
||||
|
||||
final switching = session.select(1);
|
||||
await disposeStarted.future;
|
||||
|
||||
expect(retiring.disposed, isFalse);
|
||||
expect(session.active, isNull);
|
||||
expect(session.preparedPlayerAt(0), isNull);
|
||||
|
||||
disposeGate.complete();
|
||||
await switching;
|
||||
expect(retiring.disposed, isTrue);
|
||||
expect(session.active, isA<FakePlayer>());
|
||||
await session.close();
|
||||
},
|
||||
);
|
||||
|
||||
test('should_keep_user_pause_when_reselecting_the_same_episode', () async {
|
||||
final factory = FakeFactory();
|
||||
final session = PlaybackSession(
|
||||
@@ -164,8 +196,14 @@ void main() {
|
||||
}
|
||||
|
||||
class FakeFactory {
|
||||
FakeFactory({this.holdFirstInitialize = false});
|
||||
FakeFactory({
|
||||
this.holdFirstInitialize = false,
|
||||
this.firstDisposeStarted,
|
||||
this.firstDisposeGate,
|
||||
});
|
||||
final bool holdFirstInitialize;
|
||||
final Completer<void>? firstDisposeStarted;
|
||||
final Completer<void>? firstDisposeGate;
|
||||
final List<FakePlayer> created = [];
|
||||
final Completer<void> firstCreated = Completer<void>();
|
||||
int live = 0;
|
||||
@@ -176,6 +214,8 @@ class FakeFactory {
|
||||
if (live > peakLive) peakLive = live;
|
||||
final player = FakePlayer(
|
||||
holdInitialize: holdFirstInitialize && created.isEmpty,
|
||||
disposeStarted: created.isEmpty ? firstDisposeStarted : null,
|
||||
disposeGate: created.isEmpty ? firstDisposeGate : null,
|
||||
onDispose: () => live--,
|
||||
);
|
||||
created.add(player);
|
||||
@@ -185,9 +225,16 @@ class FakeFactory {
|
||||
}
|
||||
|
||||
class FakePlayer implements PlayerPort {
|
||||
FakePlayer({required this.holdInitialize, required this.onDispose});
|
||||
FakePlayer({
|
||||
required this.holdInitialize,
|
||||
required this.onDispose,
|
||||
this.disposeStarted,
|
||||
this.disposeGate,
|
||||
});
|
||||
final bool holdInitialize;
|
||||
final void Function() onDispose;
|
||||
final Completer<void>? disposeStarted;
|
||||
final Completer<void>? disposeGate;
|
||||
final Completer<void> _initializeGate = Completer<void>();
|
||||
bool initialized = false;
|
||||
bool disposed = false;
|
||||
@@ -211,6 +258,10 @@ class FakePlayer implements PlayerPort {
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
if (disposed) return;
|
||||
if (disposeStarted != null && !disposeStarted!.isCompleted) {
|
||||
disposeStarted!.complete();
|
||||
}
|
||||
await disposeGate?.future;
|
||||
disposed = true;
|
||||
onDispose();
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ PlayerLogic _logic(FakeWatchHistory history, {Drama? drama}) {
|
||||
pictureInPicture: AndroidPictureInPicture(
|
||||
channel: const MethodChannel('test/progress/pip'),
|
||||
),
|
||||
waitForFrame: () async {},
|
||||
);
|
||||
if (drama != null) {
|
||||
logic.attach(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/models/play_ticket.dart';
|
||||
import 'package:fruitshorts/core/data/models/player_port.dart';
|
||||
@@ -11,8 +12,10 @@ import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
|
||||
import 'package:fruitshorts/core/data/services/download_controller.dart';
|
||||
import 'package:fruitshorts/core/data/services/download_storage.dart';
|
||||
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
||||
import 'package:fruitshorts/core/data/services/video_player_port.dart';
|
||||
import 'package:fruitshorts/pages/player/player_logic.dart';
|
||||
import 'package:fruitshorts/pages/player/player_view.dart';
|
||||
import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart';
|
||||
|
||||
import '../support/fake_watch_history.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
@@ -45,10 +48,12 @@ AndroidPictureInPicture _pip() =>
|
||||
PlayerLogic _logic(
|
||||
FakeWatchHistory history, {
|
||||
PlaybackRepository? playback,
|
||||
Future<void> Function()? waitForFrame,
|
||||
}) => PlayerLogic(
|
||||
history: history,
|
||||
playback: playback ?? FakePlaybackRepository(),
|
||||
pictureInPicture: _pip(),
|
||||
waitForFrame: waitForFrame ?? () async {},
|
||||
);
|
||||
|
||||
/// Drains the session's serialized work so a test never ends mid-disposal.
|
||||
@@ -681,6 +686,49 @@ void main() {
|
||||
logic.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'should_unmount_old_controller_before_disposing_it_when_feed_changes',
|
||||
(tester) async {
|
||||
final factory = _VideoRecordingFactory();
|
||||
final initial = [_drama('old')];
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
logic.attach(
|
||||
PlayerPage(
|
||||
drama: initial.first,
|
||||
initialIndex: 0,
|
||||
homeMode: true,
|
||||
feedDramas: initial,
|
||||
),
|
||||
isMounted: () => true,
|
||||
);
|
||||
await tester.runAsync(() async {
|
||||
logic.start(
|
||||
factory: factory.create,
|
||||
coordinator: PlaybackCoordinator(),
|
||||
);
|
||||
await _drain(logic);
|
||||
});
|
||||
final oldPlayer = factory.created.single;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: _SessionVideoSurface(logic: logic)),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byType(VideoPlayer), findsOneWidget);
|
||||
|
||||
logic.updateFeed([_drama('new')], resetToFirst: true);
|
||||
for (var frame = 0; frame < 5; frame++) {
|
||||
await tester.pump();
|
||||
await tester.runAsync(pumpEventQueue);
|
||||
}
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(oldPlayer.disposed, isTrue);
|
||||
expect(factory.created.length, greaterThanOrEqualTo(2));
|
||||
logic.dispose();
|
||||
await tester.pump();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('lifecycle', () {
|
||||
@@ -889,3 +937,70 @@ class _RecordingPlayer implements PlayerPort {
|
||||
onDispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _SessionVideoSurface extends StatelessWidget {
|
||||
const _SessionVideoSurface({required this.logic});
|
||||
|
||||
final PlayerLogic logic;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: logic,
|
||||
builder: (context, _) => AnimatedBuilder(
|
||||
animation: logic.session,
|
||||
builder: (context, _) {
|
||||
final port = logic.session.active;
|
||||
final video = port is VideoPlayerPort ? port : null;
|
||||
return Scaffold(
|
||||
body: PlayerVideoSurface(
|
||||
controller: video?.controller,
|
||||
videoUrl: video?.controller.dataSource ?? '',
|
||||
theater: false,
|
||||
onTap: () {},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoRecordingFactory {
|
||||
final List<_VideoRecordingPlayer> created = [];
|
||||
|
||||
PlayerPort create(Episode episode) {
|
||||
final player = _VideoRecordingPlayer(episode);
|
||||
created.add(player);
|
||||
return player;
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoRecordingPlayer extends VideoPlayerPort {
|
||||
_VideoRecordingPlayer(super.episode);
|
||||
|
||||
bool disposed = false;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {
|
||||
controller.value = const VideoPlayerValue(
|
||||
duration: Duration(minutes: 1),
|
||||
size: Size(1280, 720),
|
||||
position: Duration(seconds: 1),
|
||||
isInitialized: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() async {}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
await controller.dispose();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user