fix(player): 修复切换频道时复用已释放的视频控制器

This commit is contained in:
Zhou Weidong committed 2026-10-21 20:00:15 +08:00
1 parent 45a53180d9
commit f3ec12e3c3
6 files changed
+229 -6

No files matched your search

+53 -2
View File
@@ -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(
+115
View File
@@ -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();
}
}