fix(tvos): stabilize remote input and player build

This commit is contained in:
edde746
2026-05-06 08:47:13 +02:00
parent 4700cc5bd4
commit de547884f8
4 changed files with 151 additions and 11 deletions
+17 -6
View File
@@ -45,7 +45,9 @@ class MpvPlayerCore: MpvPlayerCoreBase {
}
setupNotifications()
ExternalDisplayManager.shared.attach(core: self)
#if os(iOS)
ExternalDisplayManager.shared.attach(core: self)
#endif
isInitialized = true
print("[MpvPlayerCore] Initialized successfully with MPV")
@@ -128,10 +130,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
private func refreshExternalDisplayAttachment() {
guard let containerView else { return }
let externalSuperview =
isVisible && !isPipActive && !isPipStarting
? ExternalDisplayManager.shared.videoSuperview
: nil
let externalSuperview = externalVideoSuperview
if let externalSuperview {
moveContainerView(to: externalSuperview)
@@ -147,6 +146,16 @@ class MpvPlayerCore: MpvPlayerCoreBase {
updateFrame()
}
private var externalVideoSuperview: UIView? {
#if os(iOS)
isVisible && !isPipActive && !isPipStarting
? ExternalDisplayManager.shared.videoSuperview
: nil
#else
nil
#endif
}
private func moveContainerView(to superview: UIView) {
guard let containerView else { return }
@@ -207,7 +216,9 @@ class MpvPlayerCore: MpvPlayerCoreBase {
func dispose() {
NotificationCenter.default.removeObserver(self)
ExternalDisplayManager.shared.detach(core: self)
#if os(iOS)
ExternalDisplayManager.shared.detach(core: self)
#endif
disposeSharedState(destroySynchronously: false)
metalLayer?.removeFromSuperlayer()
+24 -2
View File
@@ -123,7 +123,11 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
}
private var isSceneActive: Bool {
ExternalDisplayManager.hasActiveApplicationScene
#if os(iOS)
ExternalDisplayManager.hasActiveApplicationScene
#else
UIApplication.shared.connectedScenes.contains { $0.activationState == .foregroundActive }
#endif
}
private func restoreInlinePlayerAfterPip() {
@@ -362,7 +366,25 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
// MARK: - Helpers
private func findKeyWindow() -> UIWindow? {
ExternalDisplayManager.mainApplicationWindow()
#if os(iOS)
return ExternalDisplayManager.mainApplicationWindow()
#else
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
for scene in scenes {
if let window = scene.windows.first(where: { $0.isKeyWindow }) {
return window
}
}
for scene in scenes {
if let window = scene.windows.first(where: { !$0.isHidden }) {
return window
}
}
return scenes.first?.windows.first
#endif
}
}
@@ -5,11 +5,14 @@ import '../utils/app_logger.dart';
import '../utils/key_event_simulator.dart' as key_sim;
import 'gamepad_service.dart';
enum _SwipeAxis { horizontal, vertical }
/// Bridges tvOS touch-surface events from Apple's iOS Remote app into the
/// focus-tree key events Plezy already handles for D-pad navigation.
class AppleTvRemoteTouchService {
static const String _channelName = 'flutter/gamepadtouchevent';
static const double defaultSwipeThreshold = 180;
static const double defaultAxisSwitchDominanceRatio = 1.5;
static const Duration defaultSwipeRepeatInterval = Duration(milliseconds: 140);
static const Duration defaultClickAfterDirectionSuppression = Duration(milliseconds: 220);
@@ -21,14 +24,18 @@ class AppleTvRemoteTouchService {
final DateTime Function() _now;
final GamepadDuplicateInputGuard _duplicateInputGuard;
final double swipeThreshold;
final double axisSwitchDominanceRatio;
final Duration swipeRepeatInterval;
final Duration clickAfterDirectionSuppression;
bool _listening = false;
bool _nativeKeyHandlerRegistered = false;
bool _touchActive = false;
double _startX = 0;
double _startY = 0;
double _anchorX = 0;
double _anchorY = 0;
_SwipeAxis? _lastSwipeAxis;
DateTime? _lastSwipeAt;
DateTime? _lastDirectionalInputAt;
DateTime? _lastSyntheticSelectAt;
@@ -41,9 +48,11 @@ class AppleTvRemoteTouchService {
GamepadDuplicateInputGuard? duplicateInputGuard,
Duration duplicateSuppressionWindow = GamepadDuplicateInputGuard.defaultSuppressionWindow,
this.swipeThreshold = defaultSwipeThreshold,
this.axisSwitchDominanceRatio = defaultAxisSwitchDominanceRatio,
this.swipeRepeatInterval = defaultSwipeRepeatInterval,
this.clickAfterDirectionSuppression = defaultClickAfterDirectionSuppression,
}) : _channel = channel ?? const BasicMessageChannel<dynamic>(_channelName, JSONMessageCodec()),
}) : assert(axisSwitchDominanceRatio >= 1),
_channel = channel ?? const BasicMessageChannel<dynamic>(_channelName, JSONMessageCodec()),
_simulateKeyPress = simulateKeyPress ?? key_sim.simulateKeyPress,
_scheduleFrame = scheduleFrame ?? key_sim.scheduleFrameIfIdle,
_now = now ?? DateTime.now,
@@ -132,8 +141,11 @@ class AppleTvRemoteTouchService {
void _startTouch(double x, double y) {
_touchActive = true;
_startX = x;
_startY = y;
_anchorX = x;
_anchorY = y;
_lastSwipeAxis = null;
_lastSwipeAt = null;
}
@@ -145,7 +157,8 @@ class AppleTvRemoteTouchService {
final deltaX = _anchorX - x;
final deltaY = _anchorY - y;
if (deltaX.abs() < swipeThreshold && deltaY.abs() < swipeThreshold) return;
final axis = _resolveSwipeAxis(x: x, y: y, deltaX: deltaX, deltaY: deltaY);
if (axis == null) return;
final now = _now();
final lastSwipeAt = _lastSwipeAt;
@@ -157,16 +170,49 @@ class AppleTvRemoteTouchService {
return;
}
final logicalKey = deltaX.abs() >= deltaY.abs()
final logicalKey = axis == _SwipeAxis.horizontal
? (deltaX >= 0 ? LogicalKeyboardKey.arrowLeft : LogicalKeyboardKey.arrowRight)
: (deltaY >= 0 ? LogicalKeyboardKey.arrowUp : LogicalKeyboardKey.arrowDown);
_emitKey(logicalKey, source: 'swipe', detail: 'dx=${_formatDouble(deltaX)} dy=${_formatDouble(deltaY)}');
_anchorX = x;
_anchorY = y;
_lastSwipeAxis = axis;
_lastSwipeAt = now;
}
_SwipeAxis? _resolveSwipeAxis({
required double x,
required double y,
required double deltaX,
required double deltaY,
}) {
final absX = deltaX.abs();
final absY = deltaY.abs();
if (absX < swipeThreshold && absY < swipeThreshold) return null;
final candidate = absX >= absY ? _SwipeAxis.horizontal : _SwipeAxis.vertical;
final lastAxis = _lastSwipeAxis;
if (lastAxis == null || candidate == lastAxis) return candidate;
final totalX = (_startX - x).abs();
final totalY = (_startY - y).abs();
final candidateTotal = _axisDistance(candidate, totalX, totalY);
final lastAxisTotal = _axisDistance(lastAxis, totalX, totalY);
final candidateSegment = _axisDistance(candidate, absX, absY);
final lastAxisSegment = _axisDistance(lastAxis, absX, absY);
if (candidateTotal >= lastAxisTotal * axisSwitchDominanceRatio &&
candidateSegment >= lastAxisSegment * axisSwitchDominanceRatio) {
return candidate;
}
return lastAxisSegment >= swipeThreshold ? lastAxis : null;
}
double _axisDistance(_SwipeAxis axis, double horizontal, double vertical) {
return axis == _SwipeAxis.horizontal ? horizontal : vertical;
}
void _emitSelect() {
final now = _now();
final lastDirectionalInputAt = _lastDirectionalInputAt;
@@ -210,6 +256,7 @@ class AppleTvRemoteTouchService {
void _resetTouch() {
_touchActive = false;
_lastSwipeAxis = null;
_lastSwipeAt = null;
}
@@ -30,6 +30,66 @@ void main() {
expect(harness.keys, [LogicalKeyboardKey.arrowUp]);
});
test('keeps horizontal axis through non-decisive vertical drift', () async {
final harness = _Harness();
await harness.send('started', x: 500, y: 500);
await harness.send('move', x: 380, y: 500);
harness.advance(const Duration(milliseconds: 141));
await harness.send('move', x: 380, y: 370);
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
});
test('continues horizontal swipes when drift is slightly vertical-dominant', () async {
final harness = _Harness();
await harness.send('started', x: 500, y: 500);
await harness.send('move', x: 380, y: 500);
harness.advance(const Duration(milliseconds: 141));
await harness.send('move', x: 260, y: 370);
expect(harness.keys, [LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowLeft]);
});
test('continues reversed horizontal swipes when drift is only slightly vertical-dominant', () async {
final harness = _Harness();
await harness.send('started', x: 500, y: 500);
await harness.send('move', x: 380, y: 500);
harness.advance(const Duration(milliseconds: 141));
await harness.send('move', x: 500, y: 370);
expect(harness.keys, [LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowRight]);
});
test('switches axis when the new direction clearly dominates the gesture', () async {
final harness = _Harness();
await harness.send('started', x: 500, y: 500);
await harness.send('move', x: 380, y: 500);
harness.advance(const Duration(milliseconds: 141));
await harness.send('move', x: 380, y: 300);
expect(harness.keys, [LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowUp]);
});
test('resets swipe axis hysteresis between touches', () async {
final harness = _Harness();
await harness.send('started', x: 500, y: 500);
await harness.send('move', x: 380, y: 500);
await harness.send('ended', x: 380, y: 500);
await harness.send('started', x: 500, y: 500);
await harness.send('move', x: 500, y: 380);
expect(harness.keys, [LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowUp]);
});
test('short touch without a click event does not emit select', () async {
final harness = _Harness();