fix(tv): handle remote play/pause at the screen focus level

When focus drifts to the player's screen-level Focus node (after an
overlay sheet closes with the controls hidden), a hardware play/pause
key only revealed the chrome and leaked to Android's MediaSession,
which pauses unreliably when its state is throttled or suspended.
Intercept media play/pause at the screen node on TV-style navigation:
toggle via the same playback-intent path as the Apple TV bridge and
consume the event.

close #1375
This commit is contained in:
edde746
2026-07-04 21:33:45 +02:00
parent 6af4289289
commit 6b7aac3372
+44 -14
View File
@@ -1214,23 +1214,29 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
Future<void> _handleAppleTvRemotePlayPause(AppleTvRemotePlayPauseAction action) async {
if (!mounted || ModalRoute.of(context)?.isCurrent != true) return;
final currentPlayer = player;
if (!_isPlayerInitialized || currentPlayer == null) {
appLogger.d('Apple TV remote play/pause ignored: player not ready');
return;
}
if (!_canControlPlaybackFromRemote()) {
appLogger.d('Apple TV remote play/pause ignored: playback control unavailable');
return;
}
appLogger.d(
'Apple TV remote play/pause received source=${action.source}'
'${action.detail == null ? '' : ' detail=${action.detail}'}',
);
await _toggleRemotePlayPause(source: 'Apple TV remote');
}
/// Toggle play/pause on behalf of a hardware remote (Apple TV bridge or a
/// hardware media key). Mirrors the controls path: rewind-on-resume, then
/// play/pause with playback intent.
Future<void> _toggleRemotePlayPause({required String source}) async {
if (!mounted || ModalRoute.of(context)?.isCurrent != true) return;
final currentPlayer = player;
if (!_isPlayerInitialized || currentPlayer == null) {
appLogger.d('$source play/pause ignored: player not ready');
return;
}
if (!_canControlPlaybackFromRemote()) {
appLogger.d('$source play/pause ignored: playback control unavailable');
return;
}
try {
if (!currentPlayer.state.playing) {
@@ -1239,10 +1245,17 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
await _playOrPauseWithPlaybackIntent(currentPlayer);
} catch (e, st) {
appLogger.w('Apple TV remote play/pause failed', error: e, stackTrace: st);
appLogger.w('$source play/pause failed', error: e, stackTrace: st);
}
}
/// Hardware media play/pause keys (Android TV remotes). Deliberately not
/// space/configured hotkeys — text fields must still receive those.
static bool _isHardwarePlayPauseKey(LogicalKeyboardKey key) =>
key == LogicalKeyboardKey.mediaPlayPause ||
key == LogicalKeyboardKey.mediaPlay ||
key == LogicalKeyboardKey.mediaPause;
bool _canControlPlaybackFromRemote() {
try {
final watchTogether = _watchTogetherProvider ?? context.read<WatchTogetherProvider>();
@@ -1330,6 +1343,23 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Back keys pass through — handled by PopScope (system back
// gesture) or overlay sheet's onKeyEvent.
if (event.logicalKey.isBackKey) return KeyEventResult.ignored;
// Hardware media play/pause must act even when focus rests on this
// node or a sibling overlay — otherwise the key only reveals the
// chrome and leaks to the (possibly stale/suspended) Android
// MediaSession (#1375). Gated to TV-style nav: on desktop the global
// HardwareKeyboard handler already acts (handlers don't stop focus
// dispatch), and Apple TV delivers play/pause via its native bridge.
if (_videoPlayerNavigationEnabled &&
!PlatformDetector.isAppleTV() &&
_isHardwarePlayPauseKey(event.logicalKey)) {
if (event is KeyDownEvent) {
unawaited(_toggleRemotePlayPause(source: 'Hardware media key'));
if (node.hasPrimaryFocus) {
_chromeController.show(focusTarget: PlayerChromeFocusTarget.playPause);
}
}
return KeyEventResult.handled; // consume down, repeat, and up
}
// Self-heal: if this node itself has primary focus (no descendant
// focused, e.g. after controls auto-hide), redirect to first descendant.
if (node.hasPrimaryFocus) {