fix(tvos): stabilize remote input and player build
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user