diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index 461e7655..acf6f26d 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -108,6 +108,11 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { return; } + if (PlatformDetector.isAppleTV() && _isPlaybackMediaControlEvent(event)) { + appLogger.d('Media control: ${event.runtimeType} ignored on Apple TV; using native remote bridge'); + return; + } + if (activePlayer == null && event is! NextTrackEvent && event is! PreviousTrackEvent) return; if (event is PlayEvent) { @@ -248,6 +253,9 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { event is AudioRouteOldDeviceUnavailableEvent || event is AudioRouteNewDeviceAvailableEvent; + bool _isPlaybackMediaControlEvent(Object event) => + event is PlayEvent || event is PauseEvent || event is TogglePlayPauseEvent; + Future _handleAppleAudioSessionEvent(Object event) async { if (!Platform.isIOS || PlatformDetector.isTV()) return; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 06b36180..35dd63e8 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -43,6 +43,7 @@ import '../services/trackers/tracker_coordinator.dart'; import '../services/trakt/trakt_scrobble_service.dart'; import '../services/episode_navigation_service.dart'; import '../services/app_foreground_service.dart'; +import '../services/apple_tv_remote_touch_service.dart'; import '../services/media_controls_manager.dart'; import '../services/playback_initialization_service.dart'; import '../services/playback_context.dart'; @@ -300,6 +301,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin StreamSubscription? _playingSubscription; StreamSubscription? _completedSubscription; StreamSubscription? _mediaControlSubscription; + StreamSubscription? _appleTvPlayPauseSubscription; StreamSubscription? _bufferingSubscription; StreamSubscription? _positionSubscription; StreamSubscription? _playbackRestartSubscription; @@ -541,6 +543,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin WidgetsBinding.instance.addObserver(this); _setupCompanionRemoteCallbacks(); + _setupAppleTvRemotePlaybackActions(); _sleepTimerSubscription = SleepTimerService().onPrompt.listen((_) { if (mounted) _showStillWatchingDialog(); @@ -1161,6 +1164,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _completedSubscription?.cancel(); _errorSubscription?.cancel(); _mediaControlSubscription?.cancel(); + _appleTvPlayPauseSubscription?.cancel(); _bufferingSubscription?.cancel(); _trackManager?.dispose(); _positionSubscription?.cancel(); @@ -1252,6 +1256,53 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } + void _setupAppleTvRemotePlaybackActions() { + if (!PlatformDetector.isAppleTV()) return; + + _appleTvPlayPauseSubscription = AppleTvRemoteTouchService.instance.playPauseActions.listen((action) { + unawaited(_handleAppleTvRemotePlayPause(action)); + }); + } + + Future _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}'}', + ); + + try { + if (!currentPlayer.state.playing) { + await _seekBackForRewind(currentPlayer); + if (!mounted || player != currentPlayer) return; + } + await currentPlayer.playOrPause(); + } catch (e, st) { + appLogger.w('Apple TV remote play/pause failed', error: e, stackTrace: st); + } + } + + bool _canControlPlaybackFromRemote() { + try { + final watchTogether = _watchTogetherProvider ?? context.read(); + return !watchTogether.isInSession || watchTogether.canControl(); + } catch (e) { + return true; + } + } + String? _lastLogError; bool _sawServer500 = false; diff --git a/lib/services/apple_tv_remote_touch_service.dart b/lib/services/apple_tv_remote_touch_service.dart index 38a019a0..cb2e9a16 100644 --- a/lib/services/apple_tv_remote_touch_service.dart +++ b/lib/services/apple_tv_remote_touch_service.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -7,6 +9,13 @@ import 'gamepad_service.dart'; enum _SwipeAxis { horizontal, vertical } +class AppleTvRemotePlayPauseAction { + final String source; + final String? detail; + + const AppleTvRemotePlayPauseAction({required this.source, this.detail}); +} + /// 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 { @@ -25,6 +34,8 @@ class AppleTvRemoteTouchService { final VoidCallback _scheduleFrame; final DateTime Function() _now; final GamepadDuplicateInputGuard _duplicateInputGuard; + final StreamController _playPauseController = + StreamController.broadcast(); final double swipeThreshold; final double axisSwitchDominanceRatio; final Duration swipeRepeatInterval; @@ -66,6 +77,8 @@ class AppleTvRemoteTouchService { _duplicateInputGuard = duplicateInputGuard ?? GamepadDuplicateInputGuard(now: now, suppressionWindow: duplicateSuppressionWindow); + Stream get playPauseActions => _playPauseController.stream; + void start() { if (_listening) return; _channel.setMessageHandler(handleMessage); @@ -86,6 +99,10 @@ class AppleTvRemoteTouchService { bool handleNativeKeyEvent(KeyEvent event) { _log('native ${_eventTypeName(event)} logical=${_keyName(event.logicalKey)}'); + if (_isMediaPlaybackKey(event.logicalKey)) { + _log('consume native media key reason=direct-playback-action'); + return true; + } if (event is KeyDownEvent && _isDirectionalKey(event.logicalKey)) { _lastDirectionalInputAt = _now(); } @@ -128,6 +145,11 @@ class AppleTvRemoteTouchService { _releaseSelectFromClick(source: 'click_e'); case 'click_s': _pressSelectFromClick(); + case 'play_pause': + final source = arguments['source'] is String ? arguments['source'] as String : 'native'; + final detail = arguments['detail'] is String ? arguments['detail'] as String : null; + _log('emit action=play_pause source=$source${detail == null ? '' : ' detail=$detail'}'); + _playPauseController.add(AppleTvRemotePlayPauseAction(source: source, detail: detail)); case 'loc': break; default: @@ -333,6 +355,9 @@ class AppleTvRemoteTouchService { if (key == LogicalKeyboardKey.select) return 'select'; if (key == LogicalKeyboardKey.gameButtonA) return 'gameButtonA'; if (key == LogicalKeyboardKey.escape) return 'escape'; + if (key == LogicalKeyboardKey.mediaPlay) return 'mediaPlay'; + if (key == LogicalKeyboardKey.mediaPause) return 'mediaPause'; + if (key == LogicalKeyboardKey.mediaPlayPause) return 'mediaPlayPause'; return '0x${key.keyId.toRadixString(16)}'; } @@ -343,6 +368,12 @@ class AppleTvRemoteTouchService { key == LogicalKeyboardKey.arrowRight; } + bool _isMediaPlaybackKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.mediaPlayPause || + key == LogicalKeyboardKey.mediaPlay || + key == LogicalKeyboardKey.mediaPause; + } + String _formatDouble(double? value) { if (value == null) return 'n/a'; return value.toStringAsFixed(1); diff --git a/pubspec.lock b/pubspec.lock index 4676c929..e9357a84 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -768,11 +768,11 @@ packages: dependency: "direct main" description: path: "." - ref: d5c1885 - resolved-ref: d5c18857ada19896cf456b832c143da435a7155d - url: "https://github.com/edde746/os-media-controls" + ref: f51c805ebc15bf7a2f49a74174aeb470d3c4c78e + resolved-ref: f51c805ebc15bf7a2f49a74174aeb470d3c4c78e + url: "https://github.com/edde746/media_controls" source: git - version: "0.2.1" + version: "0.2.4" package_config: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f2f4a05f..cea45336 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,8 +34,8 @@ dependencies: path: packages/connectivity_plus/connectivity_plus os_media_controls: git: - url: https://github.com/edde746/os-media-controls - ref: d5c1885 + url: https://github.com/edde746/media_controls + ref: f51c805ebc15bf7a2f49a74174aeb470d3c4c78e rate_limiter: ^1.0.0 wakelock_plus: git: diff --git a/tvos/Podfile b/tvos/Podfile index f6ac18b5..c7c98cab 100644 --- a/tvos/Podfile +++ b/tvos/Podfile @@ -33,6 +33,25 @@ def tvos_flutter_engine_path raise "tvos/Podfile: FLUTTER_LOCAL_ENGINE missing from Flutter/Generated.xcconfig" end +def tvos_engine_output(engine, *variants) + variants.each do |variant| + candidate = File.join(engine, 'out', variant) + return candidate if Dir.exist?(candidate) + end + + raise "tvos/Podfile: none of these Flutter engine outputs exist: #{variants.join(', ')}" +end + +def tvos_depends_on_flutter?(target, visited = {}) + return false if visited[target.uuid] + + visited[target.uuid] = true + target.dependencies.any? do |dependency| + dependency.name == 'Flutter' || + (dependency.respond_to?(:target) && dependency.target && tvos_depends_on_flutter?(dependency.target, visited)) + end +end + target 'Runner' do use_frameworks! pod 'Flutter', :path => 'Flutter' @@ -45,22 +64,24 @@ end post_install do |installer| engine = tvos_flutter_engine_path + simulator_engine = tvos_engine_output(engine, 'tvos_debug_sim_unopt_arm64', 'tvos_debug_sim_unopt') + debug_device_engine = tvos_engine_output(engine, 'tvos_debug_unopt', 'tvos_release') + release_device_engine = tvos_engine_output(engine, 'tvos_release') installer.pods_project.targets.each do |target| target.build_configurations.each do |config| is_debug = config.name.downcase.include?('debug') - sim_variant = is_debug ? 'tvos_debug_sim_unopt_arm64' : 'tvos_release' - dev_variant = is_debug ? 'tvos_debug_unopt' : 'tvos_release' config.build_settings['FRAMEWORK_SEARCH_PATHS[sdk=appletvsimulator*]'] = [ '$(inherited)', - File.join(engine, 'out', sim_variant), + simulator_engine, ] config.build_settings['FRAMEWORK_SEARCH_PATHS[sdk=appletvos*]'] = [ '$(inherited)', - File.join(engine, 'out', dev_variant), + is_debug ? debug_device_engine : release_device_engine, ] # Engine Flutter.framework ships arm64-only for simulator; exclude x86_64 # so the link step doesn't look for a slice that doesn't exist. config.build_settings['EXCLUDED_ARCHS[sdk=appletvsimulator*]'] = 'x86_64' + config.build_settings['OTHER_LDFLAGS'] = '$(inherited) -framework Flutter' if tvos_depends_on_flutter?(target) end end end diff --git a/tvos/Runner/AppDelegate.swift b/tvos/Runner/AppDelegate.swift index 4de7ab40..4d5d3696 100644 --- a/tvos/Runner/AppDelegate.swift +++ b/tvos/Runner/AppDelegate.swift @@ -5,6 +5,113 @@ import universal_gamepad import os_media_controls import wakelock_plus +@objc class PlezyFlutterViewController: FlutterViewController { + private lazy var tvRemoteChannel = FlutterBasicMessageChannel( + name: "flutter/gamepadtouchevent", + binaryMessenger: binaryMessenger, + codec: FlutterJSONMessageCodec.sharedInstance() + ) + + override var canBecomeFirstResponder: Bool { + true + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + becomeFirstResponder() + } + + override func viewWillDisappear(_ animated: Bool) { + resignFirstResponder() + super.viewWillDisappear(animated) + } + + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + if handlePlayPausePress(presses) { + return + } + + super.pressesBegan(presses, with: event) + } + + override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { + if containsPlayPausePress(presses) { + return + } + + super.pressesEnded(presses, with: event) + } + + override func pressesCancelled(_ presses: Set, with event: UIPressesEvent?) { + if containsPlayPausePress(presses) { + return + } + + super.pressesCancelled(presses, with: event) + } + + override func remoteControlReceived(with event: UIEvent?) { + guard let event = event else { + super.remoteControlReceived(with: event) + return + } + + let subtype = event.subtype + print("PlezyTvRemote: remote control event subtype=\(remoteControlSubtypeName(subtype))") + switch subtype { + case .remoteControlPlay, .remoteControlPause, .remoteControlTogglePlayPause: + sendPlayPauseEvent(source: "remote_control", detail: remoteControlSubtypeName(subtype)) + default: + super.remoteControlReceived(with: event) + } + } + + private func handlePlayPausePress(_ presses: Set) -> Bool { + guard containsPlayPausePress(presses) else { return false } + + sendPlayPauseEvent(source: "presses", detail: "playPause") + return true + } + + private func containsPlayPausePress(_ presses: Set) -> Bool { + presses.contains { press in + press.type == .playPause + } + } + + private func sendPlayPauseEvent(source: String, detail: String) { + print("PlezyTvRemote: intercepted play/pause source=\(source) detail=\(detail)") + tvRemoteChannel.sendMessage(["type": "play_pause", "source": source, "detail": detail]) + } + + private func remoteControlSubtypeName(_ subtype: UIEvent.EventSubtype) -> String { + switch subtype { + case .remoteControlPlay: + return "remoteControlPlay" + case .remoteControlPause: + return "remoteControlPause" + case .remoteControlTogglePlayPause: + return "remoteControlTogglePlayPause" + case .remoteControlStop: + return "remoteControlStop" + case .remoteControlNextTrack: + return "remoteControlNextTrack" + case .remoteControlPreviousTrack: + return "remoteControlPreviousTrack" + case .remoteControlBeginSeekingForward: + return "remoteControlBeginSeekingForward" + case .remoteControlEndSeekingForward: + return "remoteControlEndSeekingForward" + case .remoteControlBeginSeekingBackward: + return "remoteControlBeginSeekingBackward" + case .remoteControlEndSeekingBackward: + return "remoteControlEndSeekingBackward" + default: + return "unknown(\(subtype.rawValue))" + } + } +} + @main @objc class AppDelegate: FlutterAppDelegate { override func application( diff --git a/tvos/Runner/Base.lproj/Main.storyboard b/tvos/Runner/Base.lproj/Main.storyboard index 67e5a3b4..c5d59624 100644 --- a/tvos/Runner/Base.lproj/Main.storyboard +++ b/tvos/Runner/Base.lproj/Main.storyboard @@ -10,7 +10,7 @@ - + diff --git a/tvos/scripts/xcode_appletv.sh b/tvos/scripts/xcode_appletv.sh index a78cbc82..ae34926f 100755 --- a/tvos/scripts/xcode_appletv.sh +++ b/tvos/scripts/xcode_appletv.sh @@ -100,16 +100,35 @@ SyncRunnerVersion() { SetPlistString "$plist" CFBundleVersion "$FLUTTER_BUILD_NUMBER" } +EngineOutputExists() { + local variant="$1" + [[ -d "$FLUTTER_LOCAL_ENGINE/out/$variant" ]] +} + +ResolveEngineOutput() { + local variant + for variant in "$@"; do + local candidate="$FLUTTER_LOCAL_ENGINE/out/$variant" + if [[ -d "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + echo " └─ERROR: none of these Flutter engine outputs exist: $*" >&2 + return 1 +} + BuildAppDebug() { # Host tools (frontend_server, patched SDK, dartaotruntime) ship in # host_release for both debug and release consumers — the frontend_server # compiles debug kernels regardless of the host build flavor. HOST_TOOLS=$FLUTTER_LOCAL_ENGINE/out/host_release if [[ "$debug_sim" == "true" ]]; then - DEVICE_TOOLS=$FLUTTER_LOCAL_ENGINE/out/tvos_debug_sim_unopt$TARGET_POSTFIX + DEVICE_TOOLS=$(ResolveEngineOutput "tvos_debug_sim_unopt$TARGET_POSTFIX" "tvos_debug_sim_unopt_arm64" "tvos_debug_sim_unopt") || return 1 else # Device build is always arm64; gn outputs `tvos_debug_unopt` without suffix. - DEVICE_TOOLS=$FLUTTER_LOCAL_ENGINE/out/tvos_debug_unopt + DEVICE_TOOLS=$(ResolveEngineOutput "tvos_debug_unopt") || return 1 fi ROOTDIR=$(dirname "$PROJECT_DIR") @@ -420,11 +439,19 @@ BuildApp() { echo " └─engine $FLUTTER_LOCAL_ENGINE" - if [[ "$PLATFORM_NAME" == "appletvsimulator" && "$build_mode" =~ "debug" ]]; then + if [[ "$PLATFORM_NAME" == "appletvsimulator" ]]; then debug_sim="true" + if [[ ! "$build_mode" =~ "debug" ]]; then + echo " └─simulator builds use the debug simulator engine" + fi BuildAppDebug elif [[ "$build_mode" =~ "debug" ]]; then - BuildAppDebug + if EngineOutputExists "tvos_debug_unopt"; then + BuildAppDebug + else + echo " └─debug tvOS device engine not found; building release Flutter app" + BuildAppRelease + fi elif [[ "$build_mode" =~ "release" ]]; then # release/archive (archive: build mode == "release" && ${ACTION} == "install") BuildAppRelease