diff --git a/ios/Runner/MpvPlayer/MpvPlayerCore.swift b/ios/Runner/MpvPlayer/MpvPlayerCore.swift index 89370ea8..a99655ec 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerCore.swift @@ -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() diff --git a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift index e8e6b068..128c1712 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift @@ -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 } } diff --git a/lib/services/apple_tv_remote_touch_service.dart b/lib/services/apple_tv_remote_touch_service.dart index 87bba8d9..09eef99d 100644 --- a/lib/services/apple_tv_remote_touch_service.dart +++ b/lib/services/apple_tv_remote_touch_service.dart @@ -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(_channelName, JSONMessageCodec()), + }) : assert(axisSwitchDominanceRatio >= 1), + _channel = channel ?? const BasicMessageChannel(_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; } diff --git a/test/services/apple_tv_remote_touch_service_test.dart b/test/services/apple_tv_remote_touch_service_test.dart index f27c8b5c..7a7a0e65 100644 --- a/test/services/apple_tv_remote_touch_service_test.dart +++ b/test/services/apple_tv_remote_touch_service_test.dart @@ -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();