fix(tvos): handle play pause remote

close #1230
This commit is contained in:
edde746
2026-06-03 14:19:23 +02:00
parent 08491511e9
commit 92bfae322e
9 changed files with 260 additions and 15 deletions
@@ -108,6 +108,11 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
return; 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 (activePlayer == null && event is! NextTrackEvent && event is! PreviousTrackEvent) return;
if (event is PlayEvent) { if (event is PlayEvent) {
@@ -248,6 +253,9 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
event is AudioRouteOldDeviceUnavailableEvent || event is AudioRouteOldDeviceUnavailableEvent ||
event is AudioRouteNewDeviceAvailableEvent; event is AudioRouteNewDeviceAvailableEvent;
bool _isPlaybackMediaControlEvent(Object event) =>
event is PlayEvent || event is PauseEvent || event is TogglePlayPauseEvent;
Future<void> _handleAppleAudioSessionEvent(Object event) async { Future<void> _handleAppleAudioSessionEvent(Object event) async {
if (!Platform.isIOS || PlatformDetector.isTV()) return; if (!Platform.isIOS || PlatformDetector.isTV()) return;
+51
View File
@@ -43,6 +43,7 @@ import '../services/trackers/tracker_coordinator.dart';
import '../services/trakt/trakt_scrobble_service.dart'; import '../services/trakt/trakt_scrobble_service.dart';
import '../services/episode_navigation_service.dart'; import '../services/episode_navigation_service.dart';
import '../services/app_foreground_service.dart'; import '../services/app_foreground_service.dart';
import '../services/apple_tv_remote_touch_service.dart';
import '../services/media_controls_manager.dart'; import '../services/media_controls_manager.dart';
import '../services/playback_initialization_service.dart'; import '../services/playback_initialization_service.dart';
import '../services/playback_context.dart'; import '../services/playback_context.dart';
@@ -300,6 +301,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
StreamSubscription<bool>? _playingSubscription; StreamSubscription<bool>? _playingSubscription;
StreamSubscription<bool>? _completedSubscription; StreamSubscription<bool>? _completedSubscription;
StreamSubscription<dynamic>? _mediaControlSubscription; StreamSubscription<dynamic>? _mediaControlSubscription;
StreamSubscription<AppleTvRemotePlayPauseAction>? _appleTvPlayPauseSubscription;
StreamSubscription<bool>? _bufferingSubscription; StreamSubscription<bool>? _bufferingSubscription;
StreamSubscription<Duration>? _positionSubscription; StreamSubscription<Duration>? _positionSubscription;
StreamSubscription<void>? _playbackRestartSubscription; StreamSubscription<void>? _playbackRestartSubscription;
@@ -541,6 +543,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_setupCompanionRemoteCallbacks(); _setupCompanionRemoteCallbacks();
_setupAppleTvRemotePlaybackActions();
_sleepTimerSubscription = SleepTimerService().onPrompt.listen((_) { _sleepTimerSubscription = SleepTimerService().onPrompt.listen((_) {
if (mounted) _showStillWatchingDialog(); if (mounted) _showStillWatchingDialog();
@@ -1161,6 +1164,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_completedSubscription?.cancel(); _completedSubscription?.cancel();
_errorSubscription?.cancel(); _errorSubscription?.cancel();
_mediaControlSubscription?.cancel(); _mediaControlSubscription?.cancel();
_appleTvPlayPauseSubscription?.cancel();
_bufferingSubscription?.cancel(); _bufferingSubscription?.cancel();
_trackManager?.dispose(); _trackManager?.dispose();
_positionSubscription?.cancel(); _positionSubscription?.cancel();
@@ -1252,6 +1256,53 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
} }
} }
void _setupAppleTvRemotePlaybackActions() {
if (!PlatformDetector.isAppleTV()) return;
_appleTvPlayPauseSubscription = AppleTvRemoteTouchService.instance.playPauseActions.listen((action) {
unawaited(_handleAppleTvRemotePlayPause(action));
});
}
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}'}',
);
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<WatchTogetherProvider>();
return !watchTogether.isInSession || watchTogether.canControl();
} catch (e) {
return true;
}
}
String? _lastLogError; String? _lastLogError;
bool _sawServer500 = false; bool _sawServer500 = false;
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@@ -7,6 +9,13 @@ import 'gamepad_service.dart';
enum _SwipeAxis { horizontal, vertical } 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 /// Bridges tvOS touch-surface events from Apple's iOS Remote app into the
/// focus-tree key events Plezy already handles for D-pad navigation. /// focus-tree key events Plezy already handles for D-pad navigation.
class AppleTvRemoteTouchService { class AppleTvRemoteTouchService {
@@ -25,6 +34,8 @@ class AppleTvRemoteTouchService {
final VoidCallback _scheduleFrame; final VoidCallback _scheduleFrame;
final DateTime Function() _now; final DateTime Function() _now;
final GamepadDuplicateInputGuard _duplicateInputGuard; final GamepadDuplicateInputGuard _duplicateInputGuard;
final StreamController<AppleTvRemotePlayPauseAction> _playPauseController =
StreamController<AppleTvRemotePlayPauseAction>.broadcast();
final double swipeThreshold; final double swipeThreshold;
final double axisSwitchDominanceRatio; final double axisSwitchDominanceRatio;
final Duration swipeRepeatInterval; final Duration swipeRepeatInterval;
@@ -66,6 +77,8 @@ class AppleTvRemoteTouchService {
_duplicateInputGuard = _duplicateInputGuard =
duplicateInputGuard ?? GamepadDuplicateInputGuard(now: now, suppressionWindow: duplicateSuppressionWindow); duplicateInputGuard ?? GamepadDuplicateInputGuard(now: now, suppressionWindow: duplicateSuppressionWindow);
Stream<AppleTvRemotePlayPauseAction> get playPauseActions => _playPauseController.stream;
void start() { void start() {
if (_listening) return; if (_listening) return;
_channel.setMessageHandler(handleMessage); _channel.setMessageHandler(handleMessage);
@@ -86,6 +99,10 @@ class AppleTvRemoteTouchService {
bool handleNativeKeyEvent(KeyEvent event) { bool handleNativeKeyEvent(KeyEvent event) {
_log('native ${_eventTypeName(event)} logical=${_keyName(event.logicalKey)}'); _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)) { if (event is KeyDownEvent && _isDirectionalKey(event.logicalKey)) {
_lastDirectionalInputAt = _now(); _lastDirectionalInputAt = _now();
} }
@@ -128,6 +145,11 @@ class AppleTvRemoteTouchService {
_releaseSelectFromClick(source: 'click_e'); _releaseSelectFromClick(source: 'click_e');
case 'click_s': case 'click_s':
_pressSelectFromClick(); _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': case 'loc':
break; break;
default: default:
@@ -333,6 +355,9 @@ class AppleTvRemoteTouchService {
if (key == LogicalKeyboardKey.select) return 'select'; if (key == LogicalKeyboardKey.select) return 'select';
if (key == LogicalKeyboardKey.gameButtonA) return 'gameButtonA'; if (key == LogicalKeyboardKey.gameButtonA) return 'gameButtonA';
if (key == LogicalKeyboardKey.escape) return 'escape'; 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)}'; return '0x${key.keyId.toRadixString(16)}';
} }
@@ -343,6 +368,12 @@ class AppleTvRemoteTouchService {
key == LogicalKeyboardKey.arrowRight; key == LogicalKeyboardKey.arrowRight;
} }
bool _isMediaPlaybackKey(LogicalKeyboardKey key) {
return key == LogicalKeyboardKey.mediaPlayPause ||
key == LogicalKeyboardKey.mediaPlay ||
key == LogicalKeyboardKey.mediaPause;
}
String _formatDouble(double? value) { String _formatDouble(double? value) {
if (value == null) return 'n/a'; if (value == null) return 'n/a';
return value.toStringAsFixed(1); return value.toStringAsFixed(1);
+4 -4
View File
@@ -768,11 +768,11 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: d5c1885 ref: f51c805ebc15bf7a2f49a74174aeb470d3c4c78e
resolved-ref: d5c18857ada19896cf456b832c143da435a7155d resolved-ref: f51c805ebc15bf7a2f49a74174aeb470d3c4c78e
url: "https://github.com/edde746/os-media-controls" url: "https://github.com/edde746/media_controls"
source: git source: git
version: "0.2.1" version: "0.2.4"
package_config: package_config:
dependency: transitive dependency: transitive
description: description:
+2 -2
View File
@@ -34,8 +34,8 @@ dependencies:
path: packages/connectivity_plus/connectivity_plus path: packages/connectivity_plus/connectivity_plus
os_media_controls: os_media_controls:
git: git:
url: https://github.com/edde746/os-media-controls url: https://github.com/edde746/media_controls
ref: d5c1885 ref: f51c805ebc15bf7a2f49a74174aeb470d3c4c78e
rate_limiter: ^1.0.0 rate_limiter: ^1.0.0
wakelock_plus: wakelock_plus:
git: git:
+25 -4
View File
@@ -33,6 +33,25 @@ def tvos_flutter_engine_path
raise "tvos/Podfile: FLUTTER_LOCAL_ENGINE missing from Flutter/Generated.xcconfig" raise "tvos/Podfile: FLUTTER_LOCAL_ENGINE missing from Flutter/Generated.xcconfig"
end 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 target 'Runner' do
use_frameworks! use_frameworks!
pod 'Flutter', :path => 'Flutter' pod 'Flutter', :path => 'Flutter'
@@ -45,22 +64,24 @@ end
post_install do |installer| post_install do |installer|
engine = tvos_flutter_engine_path 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| installer.pods_project.targets.each do |target|
target.build_configurations.each do |config| target.build_configurations.each do |config|
is_debug = config.name.downcase.include?('debug') 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*]'] = [ config.build_settings['FRAMEWORK_SEARCH_PATHS[sdk=appletvsimulator*]'] = [
'$(inherited)', '$(inherited)',
File.join(engine, 'out', sim_variant), simulator_engine,
] ]
config.build_settings['FRAMEWORK_SEARCH_PATHS[sdk=appletvos*]'] = [ config.build_settings['FRAMEWORK_SEARCH_PATHS[sdk=appletvos*]'] = [
'$(inherited)', '$(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 # 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. # 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['EXCLUDED_ARCHS[sdk=appletvsimulator*]'] = 'x86_64'
config.build_settings['OTHER_LDFLAGS'] = '$(inherited) -framework Flutter' if tvos_depends_on_flutter?(target)
end end
end end
end end
+107
View File
@@ -5,6 +5,113 @@ import universal_gamepad
import os_media_controls import os_media_controls
import wakelock_plus 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<UIPress>, with event: UIPressesEvent?) {
if handlePlayPausePress(presses) {
return
}
super.pressesBegan(presses, with: event)
}
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
if containsPlayPausePress(presses) {
return
}
super.pressesEnded(presses, with: event)
}
override func pressesCancelled(_ presses: Set<UIPress>, 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<UIPress>) -> Bool {
guard containsPlayPausePress(presses) else { return false }
sendPlayPauseEvent(source: "presses", detail: "playPause")
return true
}
private func containsPlayPausePress(_ presses: Set<UIPress>) -> 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 @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate {
override func application( override func application(
+1 -1
View File
@@ -10,7 +10,7 @@
<!--Flutter View Controller--> <!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu"> <scene sceneID="tne-QT-ifu">
<objects> <objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController"> <viewController id="BYZ-38-t0r" customClass="PlezyFlutterViewController" customModule="Runner" customModuleProvider="target" sceneMemberID="viewController">
<layoutGuides> <layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/> <viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/> <viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
+31 -4
View File
@@ -100,16 +100,35 @@ SyncRunnerVersion() {
SetPlistString "$plist" CFBundleVersion "$FLUTTER_BUILD_NUMBER" 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() { BuildAppDebug() {
# Host tools (frontend_server, patched SDK, dartaotruntime) ship in # Host tools (frontend_server, patched SDK, dartaotruntime) ship in
# host_release for both debug and release consumers — the frontend_server # host_release for both debug and release consumers — the frontend_server
# compiles debug kernels regardless of the host build flavor. # compiles debug kernels regardless of the host build flavor.
HOST_TOOLS=$FLUTTER_LOCAL_ENGINE/out/host_release HOST_TOOLS=$FLUTTER_LOCAL_ENGINE/out/host_release
if [[ "$debug_sim" == "true" ]]; then 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 else
# Device build is always arm64; gn outputs `tvos_debug_unopt` without suffix. # 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 fi
ROOTDIR=$(dirname "$PROJECT_DIR") ROOTDIR=$(dirname "$PROJECT_DIR")
@@ -420,11 +439,19 @@ BuildApp() {
echo " └─engine $FLUTTER_LOCAL_ENGINE" echo " └─engine $FLUTTER_LOCAL_ENGINE"
if [[ "$PLATFORM_NAME" == "appletvsimulator" && "$build_mode" =~ "debug" ]]; then if [[ "$PLATFORM_NAME" == "appletvsimulator" ]]; then
debug_sim="true" debug_sim="true"
if [[ ! "$build_mode" =~ "debug" ]]; then
echo " └─simulator builds use the debug simulator engine"
fi
BuildAppDebug BuildAppDebug
elif [[ "$build_mode" =~ "debug" ]]; then 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 elif [[ "$build_mode" =~ "release" ]]; then
# release/archive (archive: build mode == "release" && ${ACTION} == "install") # release/archive (archive: build mode == "release" && ${ACTION} == "install")
BuildAppRelease BuildAppRelease