fix(player): 隔离播放资源所有权并修复黑屏

This commit is contained in:
Zhou Weidong committed 2026-10-26 17:03:19 +08:00
1 parent 54eb5a60a8
commit bb2c729a7e
9 files changed
+223 -25

No files matched your search

@@ -15,6 +15,16 @@ class PlaybackCoordinator {
if (!valid()) return false; if (!valid()) return false;
if (_owner != session) { if (_owner != session) {
final previous = _owner; final previous = _owner;
if (previous != null && previous.owner != session.owner) {
if (kDebugMode) {
debugPrint(
'[player-owner] phase=reject-coordinator-handoff '
'owner=${session.owner.name} '
'activeOwner=${previous.owner.name}',
);
}
return false;
}
await previous?._releasePlayers(); await previous?._releasePlayers();
if (previous != null && previous.livePlayerCount != 0) return false; if (previous != null && previous.livePlayerCount != 0) return false;
_owner = null; _owner = null;
@@ -56,6 +66,10 @@ Future<void> _parkNeighbors(
Future<void> _placeNeighbor(PlaybackSession session, _PlayerSlot? slot) async { Future<void> _placeNeighbor(PlaybackSession session, _PlayerSlot? slot) async {
if (slot == null || slot == session._active || slot.retiring) return; if (slot == null || slot == session._active || slot.retiring) return;
if (slot.owner != session.owner) {
session._logOwnerRejection('park', slot);
return;
}
if (session._next == null && slot.index == session._nextIndex) { if (session._next == null && slot.index == session._nextIndex) {
session._next = slot; session._next = slot;
return; return;
+48 -3
View File
@@ -17,6 +17,8 @@ typedef PlayerFactory = PlayerPort Function(Episode episode);
typedef PlayerPreparation = typedef PlayerPreparation =
Future<void> Function(Episode episode, PlayerPort player); Future<void> Function(Episode episode, PlayerPort player);
enum PlaybackSessionOwner { home, player }
/// Owns current plus previous/next frames; allocation stays serialized. /// Owns current plus previous/next frames; allocation stays serialized.
class PlaybackSession extends ChangeNotifier { class PlaybackSession extends ChangeNotifier {
PlaybackSession({ PlaybackSession({
@@ -27,6 +29,8 @@ class PlaybackSession extends ChangeNotifier {
this.activate, this.activate,
PlaybackCoordinator? coordinator, PlaybackCoordinator? coordinator,
this.waitForFrame, this.waitForFrame,
this.owner = PlaybackSessionOwner.player,
this.dramaIdAt,
}) : _coordinator = coordinator ?? PlaybackCoordinator(); }) : _coordinator = coordinator ?? PlaybackCoordinator();
final List<Episode> episodes; final List<Episode> episodes;
@@ -39,6 +43,8 @@ class PlaybackSession extends ChangeNotifier {
/// Runs only when a slot becomes current (e.g. progress listeners). /// Runs only when a slot becomes current (e.g. progress listeners).
final PlayerPreparation? activate; final PlayerPreparation? activate;
final Future<void> Function()? waitForFrame; final Future<void> Function()? waitForFrame;
final PlaybackSessionOwner owner;
final String? Function(int index)? dramaIdAt;
final PlaybackCoordinator _coordinator; final PlaybackCoordinator _coordinator;
final Set<_PlayerSlot> _live = {}; final Set<_PlayerSlot> _live = {};
_PlayerSlot? _active; _PlayerSlot? _active;
@@ -161,7 +167,12 @@ class PlaybackSession extends ChangeNotifier {
if (_live.length >= maxLivePlayers) { if (_live.length >= maxLivePlayers) {
throw StateError('Playback resource limit reached'); throw StateError('Playback resource limit reached');
} }
final slot = _PlayerSlot(index, factory(episodes[index])); final slot = _PlayerSlot(
index,
factory(episodes[index]),
owner: owner,
dramaId: dramaIdAt?.call(index),
);
_live.add(slot); _live.add(slot);
slot.ready = () async { slot.ready = () async {
try { try {
@@ -180,7 +191,19 @@ class PlaybackSession extends ChangeNotifier {
} }
Future<void> _retire(_PlayerSlot? slot) async { Future<void> _retire(_PlayerSlot? slot) async {
if (slot == null || !_live.contains(slot)) return; if (slot == null) return;
if (slot.owner != owner) {
_logOwnerRejection('retire', slot);
return;
}
if (!_live.contains(slot)) return;
if (kDebugMode) {
debugPrint(
'[player-owner] phase=retire owner=${slot.owner.name} '
'drama=${slot.dramaId ?? '-'} episodeIndex=${slot.index} '
'controller=${_playerIdentity(slot.player)}',
);
}
slot.retiring = true; slot.retiring = true;
final cleared = final cleared =
_active == slot || _active == slot ||
@@ -460,6 +483,15 @@ class PlaybackSession extends ChangeNotifier {
if (!_closed) notifyListeners(); if (!_closed) notifyListeners();
} }
void _logOwnerRejection(String phase, _PlayerSlot slot) {
if (!kDebugMode) return;
debugPrint(
'[player-owner] phase=reject-$phase expected=${owner.name} '
'actual=${slot.owner.name} drama=${slot.dramaId ?? '-'} '
'episodeIndex=${slot.index} controller=${_playerIdentity(slot.player)}',
);
}
Future<void> close() async { Future<void> close() async {
if (_closed) return; if (_closed) return;
_closed = true; _closed = true;
@@ -470,9 +502,16 @@ class PlaybackSession extends ChangeNotifier {
} }
class _PlayerSlot { class _PlayerSlot {
_PlayerSlot(this.index, this.player); _PlayerSlot(
this.index,
this.player, {
required this.owner,
required this.dramaId,
});
int index; int index;
final PlayerPort player; final PlayerPort player;
final PlaybackSessionOwner owner;
final String? dramaId;
late final Future<void> ready; late final Future<void> ready;
bool prepared = false; bool prepared = false;
bool activated = false; bool activated = false;
@@ -481,3 +520,9 @@ class _PlayerSlot {
bool reusedWarm = false; bool reusedWarm = false;
Object? failure; Object? failure;
} }
String _playerIdentity(PlayerPort player) {
return player is VideoPlayerPort
? '${identityHashCode(player.controller)}'
: '${identityHashCode(player)}';
}
+4 -1
View File
@@ -290,6 +290,10 @@ class PlayerLogic extends ChangeNotifier {
activate: activatePlayer, activate: activatePlayer,
coordinator: coordinator, coordinator: coordinator,
waitForFrame: _waitForFrame, waitForFrame: _waitForFrame,
owner: homeMode ? PlaybackSessionOwner.home : PlaybackSessionOwner.player,
dramaIdAt: (index) => index >= 0 && index < queue.length
? queue.entryAt(index).drama.id
: null,
); );
} }
@@ -1231,7 +1235,6 @@ class PlayerLogic extends ChangeNotifier {
initialIndex: index, initialIndex: index,
wallet: wallet, wallet: wallet,
downloads: downloads, downloads: downloads,
isolatePlaybackSession: true,
), ),
), ),
); );
+42 -7
View File
@@ -90,7 +90,7 @@ class PlayerPage extends StatefulWidget {
this.wallet, this.wallet,
this.downloads, this.downloads,
this.handoff, this.handoff,
this.isolatePlaybackSession = false, this.revealAfterFirstFrame = false,
}); });
final Drama drama; final Drama drama;
@@ -118,7 +118,7 @@ class PlayerPage extends StatefulWidget {
final WalletController? wallet; final WalletController? wallet;
final DownloadController? downloads; final DownloadController? downloads;
final PlayerPageHandoff? handoff; final PlayerPageHandoff? handoff;
final bool isolatePlaybackSession; final bool revealAfterFirstFrame;
@override @override
State<PlayerPage> createState() => _PlayerPageState(); State<PlayerPage> createState() => _PlayerPageState();
@@ -141,6 +141,7 @@ class _PlayerPageState extends State<PlayerPage>
DateTime? _swipeStartedAt; DateTime? _swipeStartedAt;
bool _swipePrewarmCaptured = false; bool _swipePrewarmCaptured = false;
bool _presentationActive = false; bool _presentationActive = false;
late bool _firstFrameReady;
bool _leavingHandoff = false; bool _leavingHandoff = false;
bool _preparingFullPlayer = false; bool _preparingFullPlayer = false;
final Set<String> _precachedPosterUrls = {}; final Set<String> _precachedPosterUrls = {};
@@ -152,6 +153,7 @@ class _PlayerPageState extends State<PlayerPage>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_firstFrameReady = !widget.revealAfterFirstFrame;
final handoff = widget.handoff; final handoff = widget.handoff;
logic = PlayerBinding().create( logic = PlayerBinding().create(
history: context.read<WatchHistoryRepository>(), history: context.read<WatchHistoryRepository>(),
@@ -169,12 +171,18 @@ class _PlayerPageState extends State<PlayerPage>
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
logic.start( logic.start(
factory: context.read<PlayerFactory>(), factory: context.read<PlayerFactory>(),
coordinator: handoff == null && !widget.isolatePlaybackSession coordinator: widget.embedded
? context.read<PlaybackCoordinator>() ? context.read<PlaybackCoordinator>()
: PlaybackCoordinator(), : PlaybackCoordinator(),
initialUserWantsPlay: handoff?.sourceUserWantsPlay ?? true, initialUserWantsPlay: handoff?.sourceUserWantsPlay ?? true,
); );
_pageController = logic.pages; _pageController = logic.pages;
if (kDebugMode && widget.revealAfterFirstFrame) {
debugPrint(
'[player-entry] phase=waiting-first-frame '
'session=${identityHashCode(logic.session)}',
);
}
} }
@override @override
@@ -594,6 +602,7 @@ class _PlayerPageState extends State<PlayerPage>
PlayerRenderStack( PlayerRenderStack(
pageController: _pageController, pageController: _pageController,
slots: _renderSlots(indices), slots: _renderSlots(indices),
onCurrentVideoVisible: _handleCurrentVideoVisible,
), ),
pageLayer, pageLayer,
], ],
@@ -660,16 +669,37 @@ class _PlayerPageState extends State<PlayerPage>
), ),
), ),
); );
if (widget.handoff == null) return player; final revealContent =
!widget.revealAfterFirstFrame ||
_firstFrameReady ||
logic.session.isLocked ||
logic.ticketError != null ||
logic.blockedEpisodeId != null ||
logic.session.error != null;
final presentedPlayer = widget.revealAfterFirstFrame
? Opacity(opacity: revealContent ? 1 : 0, child: player)
: player;
if (widget.handoff == null) return presentedPlayer;
return PopScope<void>( return PopScope<void>(
canPop: _leavingHandoff, canPop: _leavingHandoff,
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
if (!didPop) unawaited(_leaveHandoff(context)); if (!didPop) unawaited(_leaveHandoff(context));
}, },
child: player, child: presentedPlayer,
); );
} }
void _handleCurrentVideoVisible() {
if (!mounted || _firstFrameReady) return;
setState(() => _firstFrameReady = true);
if (kDebugMode) {
debugPrint(
'[player-entry] phase=ready '
'session=${identityHashCode(logic.session)}',
);
}
}
void _openCurrentMore() { void _openCurrentMore() {
final entry = logic.queue.entryAt(logic.session.currentIndex); final entry = logic.queue.entryAt(logic.session.currentIndex);
_openMore(context, entry.episode); _openMore(context, entry.episode);
@@ -1176,13 +1206,18 @@ class _PlayerPageState extends State<PlayerPage>
_logFullPlayerRoute('present'); _logFullPlayerRoute('present');
setState(() => _presentationActive = true); setState(() => _presentationActive = true);
final route = Navigator.of(context).push<void>( final route = Navigator.of(context).push<void>(
MaterialPageRoute<void>( PageRouteBuilder<void>(
builder: (_) => PlayerPage( opaque: false,
barrierDismissible: false,
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
pageBuilder: (_, _, _) => PlayerPage(
drama: seriesDrama, drama: seriesDrama,
initialIndex: episodeIndex, initialIndex: episodeIndex,
handoff: handoff, handoff: handoff,
wallet: logic.wallet, wallet: logic.wallet,
downloads: logic.downloads, downloads: logic.downloads,
revealAfterFirstFrame: true,
), ),
), ),
); );
@@ -52,10 +52,12 @@ class PlayerRenderStack extends StatefulWidget {
super.key, super.key,
required this.pageController, required this.pageController,
required this.slots, required this.slots,
this.onCurrentVideoVisible,
}); });
final PageController pageController; final PageController pageController;
final Map<PlayerRenderSlotRole, PlayerRenderSlotData> slots; final Map<PlayerRenderSlotRole, PlayerRenderSlotData> slots;
final VoidCallback? onCurrentVideoVisible;
@override @override
State<PlayerRenderStack> createState() => _PlayerRenderStackState(); State<PlayerRenderStack> createState() => _PlayerRenderStackState();
@@ -68,7 +70,10 @@ class _PlayerRenderStackState extends State<PlayerRenderStack> {
void initState() { void initState() {
super.initState(); super.initState();
_lastCurrentIndex = _currentSlot.index; _lastCurrentIndex = _currentSlot.index;
_log('init current=${_lastCurrentIndex ?? '-'}'); _log(
'init current=${_lastCurrentIndex ?? '-'} '
'pageController=${identityHashCode(widget.pageController)}',
);
} }
@override @override
@@ -86,7 +91,10 @@ class _PlayerRenderStackState extends State<PlayerRenderStack> {
@override @override
void dispose() { void dispose() {
_log('dispose current=${_lastCurrentIndex ?? '-'}'); _log(
'dispose current=${_lastCurrentIndex ?? '-'} '
'pageController=${identityHashCode(widget.pageController)}',
);
super.dispose(); super.dispose();
} }
@@ -189,6 +197,9 @@ class _PlayerRenderStackState extends State<PlayerRenderStack> {
fallback: slot.fallback, fallback: slot.fallback,
warmSlotReused: slot.warmSlotReused, warmSlotReused: slot.warmSlotReused,
waitForPlaybackFrame: slot.waitForPlaybackFrame, waitForPlaybackFrame: slot.waitForPlaybackFrame,
onVideoVisible: slot.role == PlayerRenderSlotRole.current
? widget.onCurrentVideoVisible
: null,
), ),
), ),
); );
@@ -28,6 +28,7 @@ class PlayerVideoSurface extends StatefulWidget {
this.debugSlot = 'default', this.debugSlot = 'default',
this.debugIndex, this.debugIndex,
this.showFallbackBackground = true, this.showFallbackBackground = true,
this.onVideoVisible,
}); });
final VideoPlayerController? controller; final VideoPlayerController? controller;
@@ -57,6 +58,7 @@ class PlayerVideoSurface extends StatefulWidget {
final bool showFallbackBackground; final bool showFallbackBackground;
final String videoUrl; final String videoUrl;
final bool theater; final bool theater;
final VoidCallback? onVideoVisible;
@override @override
State<PlayerVideoSurface> createState() => _PlayerVideoSurfaceState(); State<PlayerVideoSurface> createState() => _PlayerVideoSurfaceState();
@@ -73,6 +75,7 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
bool _confirmationScheduled = false; bool _confirmationScheduled = false;
bool _transitioning = false; bool _transitioning = false;
bool _videoVisible = false; bool _videoVisible = false;
bool _videoVisibleNotified = false;
String? _lastLayerState; String? _lastLayerState;
@override @override
@@ -279,6 +282,7 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
}); });
} }
_logLayer('video', widget.controller?.value); _logLayer('video', widget.controller?.value);
_notifyVideoVisible();
return; return;
} }
if (initial) { if (initial) {
@@ -303,6 +307,7 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
_placeholderSnapshot = null; _placeholderSnapshot = null;
}); });
_logLayer('fade-end', widget.controller?.value); _logLayer('fade-end', widget.controller?.value);
_notifyVideoVisible();
} }
void _resetForControllerChange() { void _resetForControllerChange() {
@@ -314,9 +319,20 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
_confirmationScheduled = false; _confirmationScheduled = false;
_transitioning = false; _transitioning = false;
_videoVisible = false; _videoVisible = false;
_videoVisibleNotified = false;
_lastLayerState = null; _lastLayerState = null;
} }
void _notifyVideoVisible() {
if (_videoVisibleNotified) return;
_videoVisibleNotified = true;
final callback = widget.onVideoVisible;
if (callback == null) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) callback();
});
}
void _logLayer(String phase, VideoPlayerValue? value) { void _logLayer(String phase, VideoPlayerValue? value) {
if (!kDebugMode) return; if (!kDebugMode) return;
final fallback = phase == 'video' || phase == 'fade-end' final fallback = phase == 'video' || phase == 'fade-end'
+62
View File
@@ -32,6 +32,68 @@ void main() {
await session.close(); await session.close();
}); });
test('should_not_retire_home_controller_when_player_switches_episodes', () async {
final factory = FakeFactory();
final home = PlaybackSession(
episodes: episodes,
factory: factory.create,
canPlay: (episode) => episode.isFree,
owner: PlaybackSessionOwner.home,
);
await home.select(0, preloadIndex: 1);
await home.preloadSettled;
final homePlayer = home.active;
expect(homePlayer, isNotNull);
final player = PlaybackSession(
episodes: episodes,
factory: factory.create,
canPlay: (episode) => episode.isFree,
owner: PlaybackSessionOwner.player,
);
await player.select(0, preloadIndex: 1);
await player.select(1, previousIndex: 0);
await player.preloadSettled;
expect(home.active, same(homePlayer));
expect(
factory.created
.firstWhere((candidate) => identical(candidate, homePlayer))
.disposed,
isFalse,
);
expect(player.active, isNot(same(homePlayer)));
await player.close();
await home.close();
});
test('should_keep_home_controller_alive_when_player_session_closes', () async {
final factory = FakeFactory();
final home = PlaybackSession(
episodes: episodes,
factory: factory.create,
canPlay: (episode) => episode.isFree,
owner: PlaybackSessionOwner.home,
);
await home.select(0);
final homePlayer = home.active! as FakePlayer;
final player = PlaybackSession(
episodes: episodes,
factory: factory.create,
canPlay: (episode) => episode.isFree,
owner: PlaybackSessionOwner.player,
);
await player.select(0);
await player.select(1);
await player.close();
expect(homePlayer.disposed, isFalse);
expect(home.active, same(homePlayer));
expect(home.currentIndex, 0);
await home.close();
});
test('should_release_previous_player_immediately_when_selecting_locked', () async { test('should_release_previous_player_immediately_when_selecting_locked', () async {
final lockedEpisodes = [ final lockedEpisodes = [
Episode( Episode(
+18 -6
View File
@@ -131,7 +131,7 @@ void main() {
await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.tap(find.byKey(const Key('home-feed-full-series')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final fullPlayer = find.byType(PlayerPage); final fullPlayer = _fullPlayerFinder();
final fullLogic = _pageLogic(tester, fullPlayer); final fullLogic = _pageLogic(tester, fullPlayer);
expect(fullLogic.session, isNot(same(sourceLogic.session))); expect(fullLogic.session, isNot(same(sourceLogic.session)));
final fullPlayerPort = fullLogic.session.active! as VideoPlayerPort; final fullPlayerPort = fullLogic.session.active! as VideoPlayerPort;
@@ -223,11 +223,17 @@ void main() {
await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.tap(find.byKey(const Key('home-feed-full-series')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(PlayerPage), findsOneWidget); expect(_fullPlayerFinder(), findsOneWidget);
expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); expect(find.byKey(const Key('player-play-toggle')), findsOneWidget);
expect(find.byKey(const Key('home-feed-center-play')), findsNothing); expect(
expect(find.byKey(const Key('home-tabs-bar')), findsNothing); find.byKey(const Key('home-feed-center-play')).hitTestable(),
expect(find.byKey(const Key('recommend-top-nav')), findsNothing); findsNothing,
);
expect(find.byKey(const Key('home-tabs-bar')).hitTestable(), findsNothing);
expect(
find.byKey(const Key('recommend-top-nav')).hitTestable(),
findsNothing,
);
await tester.tap(find.byKey(const Key('player-back'))); await tester.tap(find.byKey(const Key('player-back')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -259,7 +265,7 @@ void main() {
await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.tap(find.byKey(const Key('home-feed-full-series')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final fullLogic = _pageLogic(tester, find.byType(PlayerPage)); final fullLogic = _pageLogic(tester, _fullPlayerFinder());
await tester.drag( await tester.drag(
find.byKey(const Key('player-video-tap')), find.byKey(const Key('player-video-tap')),
const Offset(0, -560), const Offset(0, -560),
@@ -1646,6 +1652,12 @@ PlayerLogic _pageLogic(WidgetTester tester, Finder page) {
return state.logic as PlayerLogic; return state.logic as PlayerLogic;
} }
Finder _fullPlayerFinder() {
return find.byWidgetPredicate(
(widget) => widget is PlayerPage && widget.handoff != null,
);
}
class _RouteVideoFactory { class _RouteVideoFactory {
final List<_RouteVideoPlayer> created = []; final List<_RouteVideoPlayer> created = [];
final List<Episode> episodes = []; final List<Episode> episodes = [];
+6 -6
View File
@@ -471,13 +471,13 @@ void main() {
await tester.tap(find.byKey(const Key('home-feed-series-name'))); await tester.tap(find.byKey(const Key('home-feed-series-name')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(PlayerPage), findsOneWidget); expect(find.byType(PlayerPage).last, findsOneWidget);
expect( expect(
tester.widget<PlayerPage>(find.byType(PlayerPage)).homeMode, tester.widget<PlayerPage>(find.byType(PlayerPage).last).homeMode,
isFalse, isFalse,
); );
expect(find.byKey(const Key('player-back')), findsOneWidget); expect(find.byKey(const Key('player-back')), findsOneWidget);
expect(find.byKey(const Key('home-tabs-bar')), findsNothing); expect(find.byKey(const Key('home-tabs-bar')).hitTestable(), findsNothing);
await tester.tap(find.byKey(const Key('player-back'))); await tester.tap(find.byKey(const Key('player-back')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('recommend-page')), findsOneWidget); expect(find.byKey(const Key('recommend-page')), findsOneWidget);
@@ -485,13 +485,13 @@ void main() {
await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.tap(find.byKey(const Key('home-feed-full-series')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(PlayerPage), findsOneWidget); expect(find.byType(PlayerPage).last, findsOneWidget);
expect( expect(
tester.widget<PlayerPage>(find.byType(PlayerPage)).homeMode, tester.widget<PlayerPage>(find.byType(PlayerPage).last).homeMode,
isFalse, isFalse,
); );
expect(find.byKey(const Key('player-back')), findsOneWidget); expect(find.byKey(const Key('player-back')), findsOneWidget);
expect(find.byKey(const Key('home-tabs-bar')), findsNothing); expect(find.byKey(const Key('home-tabs-bar')).hitTestable(), findsNothing);
await tester.tap(find.byKey(const Key('player-back'))); await tester.tap(find.byKey(const Key('player-back')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
}); });