feat(client): opt-in "Rumble on this phone" mirrors pad-0 rumble onto the device

iOS + Android: a new opt-in setting mirrors controller 1's rumble onto the
device's own actuator (Apple RumbleRenderer Actuator.device / CoreHaptics,
Android deviceBodyVibrator), so a motor-less clip-on pad still gives haptic
feedback through the phone/tablet it's clamped to. Default off; wired through
the gamepad settings on both platforms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 16:32:19 +02:00
parent 6db91cbf40
commit d58524c899
12 changed files with 239 additions and 11 deletions
@@ -49,12 +49,14 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import io.unom.punktfunk.kit.deviceBodyVibrator
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView: // The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with // the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
@@ -82,7 +84,10 @@ fun GamepadSettingsScreen(
var s by remember { mutableStateOf(initial) } var s by remember { mutableStateOf(initial) }
fun update(next: Settings) { s = next; onChange(next) } fun update(next: Settings) { s = next; onChange(next) }
val rows = buildSettingsRows(s, ::update) val context = LocalContext.current
// Gates the "Rumble on this phone" row — a TV box has no body vibrator to mirror onto.
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
val rows = buildSettingsRows(s, hasBodyVibrator, ::update)
var focus by remember { mutableIntStateOf(0) } var focus by remember { mutableIntStateOf(0) }
if (focus > rows.lastIndex) focus = rows.lastIndex if (focus > rows.lastIndex) focus = rows.lastIndex
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the // The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
@@ -257,8 +262,13 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
} }
} }
/** Build the console settings rows from the current [Settings], writing through [update]. */ /** Build the console settings rows from the current [Settings], writing through [update].
private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpRow> { * [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs). */
private fun buildSettingsRows(
s: Settings,
hasBodyVibrator: Boolean,
update: (Settings) -> Unit,
): List<GpRow> {
fun <T> choice( fun <T> choice(
id: String, header: String?, label: String, detail: String, id: String, header: String?, label: String, detail: String,
options: List<Pair<T, String>>, current: T, write: (T) -> Unit, options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
@@ -354,7 +364,18 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
"The virtual pad the host creates — Automatic matches this controller.", "The virtual pad the host creates — Automatic matches this controller.",
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad, GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
) { update(s.copy(gamepad = it)) }, ) { update(s.copy(gamepad = it)) },
) + listOfNotNull(
if (hasBodyVibrator) {
toggle(
"phoneRumble", null, "Rumble on this phone",
"Also play controller 1's rumble on this phone's own vibration motor — " +
"for clip-on pads without rumble motors.",
s.rumbleOnPhone,
) { update(s.copy(rumbleOnPhone = it)) }
} else {
null
},
) + listOf(
choice( choice(
"hud", "Interface", "Statistics overlay", "hud", "Interface", "Statistics overlay",
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " + "How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
@@ -82,6 +82,14 @@ data class Settings(
* otherwise misfire and wait out its timeout despite the host already being reachable. * otherwise misfire and wait out its timeout despite the host already being reachable.
*/ */
val autoWakeEnabled: Boolean = true, val autoWakeEnabled: Boolean = true,
/**
* Opt-in: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
* phone's own vibration motor — for clip-on gamepads that ship without rumble motors, where
* the phone body is the only actuator in the player's hands. Off by default; read once per
* session by StreamScreen (it hands GamepadFeedback the device vibrator only when set). The
* toggle is hidden on devices without a vibrator (TVs), where this would be a silent no-op.
*/
val rumbleOnPhone: Boolean = false,
) )
/** [Settings.touchMode] values; persisted by name. */ /** [Settings.touchMode] values; persisted by name. */
@@ -142,6 +150,7 @@ class SettingsStore(context: Context) {
libraryEnabled = prefs.getBoolean(K_LIBRARY, true), libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true), lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true), autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
) )
fun save(s: Settings) { fun save(s: Settings) {
@@ -162,6 +171,7 @@ class SettingsStore(context: Context) {
.putBoolean(K_LIBRARY, s.libraryEnabled) .putBoolean(K_LIBRARY, s.libraryEnabled)
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode) .putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled) .putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
.apply() .apply()
} }
@@ -197,6 +207,7 @@ class SettingsStore(context: Context) {
*/ */
const val K_LOW_LATENCY = "low_latency_mode_v2" const val K_LOW_LATENCY = "low_latency_mode_v2"
const val K_AUTO_WAKE = "auto_wake_enabled" const val K_AUTO_WAKE = "auto_wake_enabled"
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */ /** Legacy Boolean the enum replaced — read once as the migration default, never written. */
const val K_TRACKPAD = "trackpad_mode" const val K_TRACKPAD = "trackpad_mode"
@@ -69,6 +69,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import io.unom.punktfunk.kit.VideoDecoders import io.unom.punktfunk.kit.VideoDecoders
import io.unom.punktfunk.kit.deviceBodyVibrator
/** /**
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category * Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
@@ -414,6 +415,18 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont
subtitle = "What the app detects, with a live input test", subtitle = "What the app detects, with a live input test",
onClick = onOpenControllers, onClick = onOpenControllers,
) )
// Only where the device has a body vibrator to mirror onto (a TV box doesn't).
val context = LocalContext.current
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
if (hasBodyVibrator) {
ToggleRow(
title = "Rumble on this phone",
subtitle = "Also play controller 1's rumble on this phone's own vibration " +
"motor — for clip-on pads without rumble motors",
checked = s.rumbleOnPhone,
onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) },
)
}
} }
} }
@@ -41,6 +41,7 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import io.unom.punktfunk.kit.GamepadFeedback import io.unom.punktfunk.kit.GamepadFeedback
import io.unom.punktfunk.kit.GamepadRouter import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.deviceBodyVibrator
import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.VideoDecoders import io.unom.punktfunk.kit.VideoDecoders
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@@ -201,8 +202,13 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad // Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
// index via the router; poll threads stopped + joined before the router is released and the // index via the router; poll threads stopped + joined before the router is released and the
// session closed. // session closed. "Rumble on this phone" (opt-in) additionally mirrors controller 1's
val feedback = GamepadFeedback(handle, router).also { it.start() } // rumble onto the device's own vibrator — for clip-on pads without rumble motors.
val feedback = GamepadFeedback(
handle,
router,
deviceVibrator = if (initialSettings.rumbleOnPhone) deviceBodyVibrator(context) else null,
).also { it.start() }
// Free a disconnected controller's rumble/lights bindings promptly (else the open lights // Free a disconnected controller's rumble/lights bindings promptly (else the open lights
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds. // session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
router.onSlotClosed = feedback::onDeviceRemoved router.onSlotClosed = feedback::onDeviceRemoved
@@ -1,5 +1,6 @@
package io.unom.punktfunk.kit package io.unom.punktfunk.kit
import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.hardware.lights.Light import android.hardware.lights.Light
import android.hardware.lights.LightState import android.hardware.lights.LightState
@@ -33,8 +34,18 @@ import java.nio.ByteBuffer
* *
* With no controller connected (emulator) rumble/lights become logged no-ops — exactly the * With no controller connected (emulator) rumble/lights become logged no-ops — exactly the
* verification path; the `Log.i` receipt lines fire regardless of rendering hardware. * verification path; the `Log.i` receipt lines fire regardless of rendering hardware.
*
* [deviceVibrator] is the opt-in phone mirror ("Rumble on this phone", off by default): when
* non-null, rumble the host addresses to wire pad 0 (controller 1) is ALSO played on this
* device's own vibration motor — for clip-on gamepads that ship without rumble motors, where the
* phone body is the only actuator in the player's hands. StreamScreen passes it only when the
* setting is on (see [deviceBodyVibrator]).
*/ */
class GamepadFeedback(private val handle: Long, private val router: GamepadRouter?) { class GamepadFeedback(
private val handle: Long,
private val router: GamepadRouter?,
private val deviceVibrator: Vibrator? = null,
) {
private companion object { private companion object {
const val TAG = "pf.feedback" const val TAG = "pf.feedback"
const val TAG_LED: Byte = 0x01 const val TAG_LED: Byte = 0x01
@@ -127,7 +138,9 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
runCatching { hidoutThread?.join() } runCatching { hidoutThread?.join() }
rumbleThread = null rumbleThread = null
hidoutThread = null hidoutThread = null
// Threads are dead — drop any held rumble and close every lights session. // Threads are dead — drop any held rumble (incl. the phone mirror's) and close every
// lights session.
runCatching { deviceVibrator?.cancel() }
synchronized(bindsLock) { synchronized(bindsLock) {
for (b in rumbleBinds.values) b?.let { for (b in rumbleBinds.values) b?.let {
runCatching { it.vm?.cancel() } runCatching { it.vm?.cancel() }
@@ -203,6 +216,11 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
*/ */
private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) { private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) {
Log.i(TAG, "rumble pad=$pad low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return Log.i(TAG, "rumble pad=$pad low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return
// Opt-in phone mirror, BEFORE the controller-bind early-return: the exact pads this
// serves have no vibrator of their own, so their bind below is null. It follows
// controller 1 unconditionally rather than only motor-less pads — capability probing
// already decided the bind, and the user opted in.
if (pad == 0) renderDeviceRumble(low, high, durationMs)
val bind = rumbleBindFor(pad) ?: return val bind = rumbleBindFor(pad) ?: return
val lo = toAmplitude(low) val lo = toAmplitude(low)
val hi = toAmplitude(high) val hi = toAmplitude(high)
@@ -246,6 +264,29 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
} }
} }
/**
* The opt-in phone mirror: play a wire-pad-0 rumble on this device's own vibration motor —
* one physical actuator, so both wire motors blend into one effect (the same blend as the
* single-motor controller path). Same envelope semantics too: a one-shot held for the host's
* TTL, cancel on (0,0).
*/
private fun renderDeviceRumble(low: Int, high: Int, durationMs: Long) {
val v = deviceVibrator ?: return
val lo = toAmplitude(low)
val hi = toAmplitude(high)
if (lo == 0 && hi == 0) {
runCatching { v.cancel() } // (0,0) = stop
return
}
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
runCatching {
v.vibrate(
if (v.hasAmplitudeControl()) oneShot(a, durationMs)
else oneShot(VibrationEffect.DEFAULT_AMPLITUDE, durationMs)
)
}
}
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0. // 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
private fun toAmplitude(v16: Int): Int { private fun toAmplitude(v16: Int): Int {
val a = (v16 ushr 8) and 0xFF val a = (v16 ushr 8) and 0xFF
@@ -349,3 +390,18 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
} }
} }
} }
/**
* This device's own body vibrator (the phone, not a controller), or null where there is none
* (TVs) — gates the "Rumble on this phone" setting's visibility and feeds
* [GamepadFeedback.deviceVibrator] when it's on.
*/
fun deviceBodyVibrator(context: Context): Vibrator? {
val v = if (Build.VERSION.SDK_INT >= 31) {
context.getSystemService(VibratorManager::class.java)?.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
return v?.takeIf { it.hasVibrator() }
}
@@ -15,6 +15,9 @@ import PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS) #if os(iOS) || os(macOS) || os(tvOS)
import GameController import GameController
#if os(iOS)
import CoreHaptics
#endif
struct GamepadSettingsView: View { struct GamepadSettingsView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -38,6 +41,9 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true @AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault @AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
#if os(iOS)
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
#endif
@ObservedObject private var gamepads = GamepadManager.shared @ObservedObject private var gamepads = GamepadManager.shared
#if os(iOS) #if os(iOS)
@@ -230,7 +236,7 @@ struct GamepadSettingsView: View {
.map { (label: "\($0) Hz", tag: $0) } .map { (label: "\($0) Hz", tag: $0) }
let bitrate = SettingsOptions.bitrateOptions(current: bitrateKbps) let bitrate = SettingsOptions.bitrateOptions(current: bitrateKbps)
let controllers = SettingsOptions.controllerOptions(gamepads) let controllers = SettingsOptions.controllerOptions(gamepads)
return [ var list: [Row] = [
choiceRow( choiceRow(
id: "resolution", header: "Stream", icon: "aspectratio", id: "resolution", header: "Stream", icon: "aspectratio",
label: "Resolution", label: "Resolution",
@@ -329,6 +335,23 @@ struct GamepadSettingsView: View {
detail: "Turn off to use the touch interface even with a controller connected.", detail: "Turn off to use the touch interface even with a controller connected.",
value: $gamepadUIEnabled), value: $gamepadUIEnabled),
] ]
#if os(iOS)
// The device-rumble mirror slots in after "Controller type" (staying inside the
// Controller group the next row carries the "Interface" header). iPhone only in
// practice: hidden where the device itself can't play haptics (iPad).
if CHHapticEngine.capabilitiesForHardware().supportsHaptics,
let at = list.firstIndex(where: { $0.id == "padType" }) {
list.insert(
toggleRow(
id: "deviceRumble", icon: "iphone.radiowaves.left.and.right",
label: "Rumble on this iPhone",
detail: "Also play player 1's rumble on the phone's own Taptic Engine — "
+ "for clip-on pads without rumble motors.",
value: $rumbleOnDevice),
at: at + 1)
}
#endif
return list
} }
/// Resolution choices as "WxH" tags the current size is inserted when it's a custom mode /// Resolution choices as "WxH" tags the current size is inserted when it's a custom mode
@@ -1,6 +1,9 @@
// SettingsView's shared sections each setting's Section is defined exactly once here and // SettingsView's shared sections each setting's Section is defined exactly once here and
// composed by the per-platform bodies in SettingsView.swift. // composed by the per-platform bodies in SettingsView.swift.
#if os(iOS)
import CoreHaptics
#endif
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
@@ -471,6 +474,12 @@ extension SettingsView {
Text(option.label).tag(option.tag) Text(option.label).tag(option.tag)
} }
} }
#if os(iOS)
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
}
#endif
#if !os(tvOS) #if !os(tvOS)
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled) Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
#endif #endif
@@ -487,6 +496,11 @@ extension SettingsView {
// for its own footer and has no such toggle to describe. // for its own footer and has no such toggle to describe.
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(Self.controllersFooter) Text(Self.controllersFooter)
#if os(iOS)
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
Text(Self.deviceRumbleFooter)
}
#endif
#if !os(tvOS) #if !os(tvOS)
Text(Self.gamepadUIFooter) Text(Self.gamepadUIFooter)
#endif #endif
@@ -88,6 +88,13 @@ extension SettingsView {
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). " + "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
+ "Applies from the next session." + "Applies from the next session."
#if os(iOS)
static let deviceRumbleFooter =
"Rumble on this iPhone plays player 1's rumble on the phone's own Taptic Engine as "
+ "well — for clip-on controllers that have no rumble motors of their own. Applies "
+ "from the next session."
#endif
#if !os(tvOS) #if !os(tvOS)
static let gamepadUIFooter = static let gamepadUIFooter =
"When a controller connects, the host list and library switch to a controller-" "When a controller connects, the host list and library switch to a controller-"
@@ -55,6 +55,7 @@ struct SettingsView: View {
#if os(iOS) #if os(iOS)
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true @AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue @AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
@AppStorage(DefaultsKey.rumbleOnDevice) var rumbleOnDevice = false
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone. // The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
// Width class decides the initial value: nil on iPhone (show the category list first), // Width class decides the initial value: nil on iPhone (show the category list first),
// General on iPad (a two-column layout should never open with an empty detail). // General on iPad (a two-column layout should never open with an empty detail).
@@ -20,6 +20,7 @@
// (triggers off, player index unset) and its renderer silenced. // (triggers off, player index unset) and its renderer silenced.
import Combine import Combine
import CoreHaptics
import Foundation import Foundation
import GameController import GameController
@@ -50,9 +51,26 @@ public final class GamepadFeedback {
private let routingLock = NSLock() private let routingLock = NSLock()
private var rumbleByPad: [UInt8: RumbleRenderer] = [:] private var rumbleByPad: [UInt8: RumbleRenderer] = [:]
/// Opt-in device mirror (`DefaultsKey.rumbleOnDevice`, iPhone only): rumble the host
/// addresses to controller 1 (wire pad 0) is ALSO rendered on this device's own Taptic
/// Engine for phone-clip pads that ship without rumble motors, where the phone body is the
/// only actuator in the player's hands. Session-scoped (the setting is read once here); nil
/// when off or where the device has no haptic actuator.
private let deviceRumble: RumbleRenderer?
public init(connection: PunktfunkConnection, manager: GamepadManager) { public init(connection: PunktfunkConnection, manager: GamepadManager) {
self.connection = connection self.connection = connection
self.manager = manager self.manager = manager
#if os(iOS)
if UserDefaults.standard.bool(forKey: DefaultsKey.rumbleOnDevice),
CHHapticEngine.capabilitiesForHardware().supportsHaptics {
deviceRumble = RumbleRenderer(policy: .session, actuator: .device)
} else {
deviceRumble = nil
}
#else
deviceRumble = nil
#endif
// Capture self weakly in the hop too, so the inner sink's weak capture isn't shadowing // Capture self weakly in the hop too, so the inner sink's weak capture isn't shadowing
// an implicit strong one and the subscription (stored on self) never retain-cycles. // an implicit strong one and the subscription (stored on self) never retain-cycles.
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
@@ -189,6 +207,7 @@ public final class GamepadFeedback {
return r return r
} }
for r in renderers { r.stop() } for r in renderers { r.stop() }
deviceRumble?.stop()
// Drop the subscription and every dead pad's cached feedback a controller change after // Drop the subscription and every dead pad's cached feedback a controller change after
// teardown must not replay this session's triggers/LEDs. // teardown must not replay this session's triggers/LEDs.
Task { @MainActor in Task { @MainActor in
@@ -203,6 +222,10 @@ public final class GamepadFeedback {
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16, ttlMs: UInt32) { private func routeRumble(pad: UInt8, low: UInt16, high: UInt16, ttlMs: UInt32) {
let renderer = withRouting { rumbleByPad[pad] } let renderer = withRouting { rumbleByPad[pad] }
renderer?.apply(low: low, high: high, ttlMs: ttlMs) renderer?.apply(low: low, high: high, ttlMs: ttlMs)
// The opt-in device mirror follows controller 1 unconditionally the pads it exists for
// have no motors (their renderer above no-ops), and mirroring deliberately isn't gated on
// that: capability probing can't see a motor-less MFi pad, and the user opted in.
if pad == 0 { deviceRumble?.apply(low: low, high: high, ttlMs: ttlMs) }
} }
private func withRouting<R>(_ body: () -> R) -> R { private func withRouting<R>(_ body: () -> R) -> R {
@@ -119,8 +119,19 @@ final class RumbleRenderer: @unchecked Sendable {
static let manual = Policy(staleAfter: nil) static let manual = Policy(staleAfter: nil)
} }
/// Which physical actuator this renderer drives: the forwarded controller's haptics engine
/// (the default), or THIS device's own Taptic Engine (`CHHapticEngine()`) the opt-in
/// "rumble on this device" mirror for phone-clip pads that ship without rumble motors.
/// Device mode ignores `retarget`'s controller and always renders one combined motor
/// (a phone body has a single actuator).
enum Actuator {
case controller
case device
}
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive) private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
private let policy: Policy private let policy: Policy
private let actuator: Actuator
/// One finite haptic play on a motor: the player plus when (engine timeline) it expires. /// One finite haptic play on a motor: the player plus when (engine timeline) it expires.
/// A PLAIN pattern player on purpose: the controller haptics server (gamecontrollerd) /// A PLAIN pattern player on purpose: the controller haptics server (gamecontrollerd)
@@ -198,8 +209,9 @@ final class RumbleRenderer: @unchecked Sendable {
((0, 0), DispatchTime(uptimeNanoseconds: 0)) ((0, 0), DispatchTime(uptimeNanoseconds: 0))
#endif #endif
init(policy: Policy = .session) { init(policy: Policy = .session, actuator: Actuator = .controller) {
self.policy = policy self.policy = policy
self.actuator = actuator
} }
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the /// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
@@ -468,6 +480,10 @@ final class RumbleRenderer: @unchecked Sendable {
/// high = right/light the Xbox/XInput convention the wire carries); one combined /// high = right/light the Xbox/XInput convention the wire carries); one combined
/// engine otherwise, driven by whichever amplitude is stronger. /// engine otherwise, driven by whichever amplitude is stronger.
private func setup() { private func setup() {
if actuator == .device {
setupDevice()
return
}
guard let haptics = controller?.haptics else { guard let haptics = controller?.haptics else {
// No haptics engine at all an Xbox controller on an OS/firmware that doesn't expose // No haptics engine at all an Xbox controller on an OS/firmware that doesn't expose
// rumble through GameController (works on Android via the standard Vibrator path, but // rumble through GameController (works on Android via the standard Vibrator path, but
@@ -517,10 +533,41 @@ final class RumbleRenderer: @unchecked Sendable {
} }
} }
/// Device-actuator mode: one combined motor on this device's own Taptic Engine. Only an
/// iPhone has one everything else (iPad, Mac, TV) reports no haptic hardware and latches
/// off (nothing to retry; the settings toggle is hidden there anyway, this is the backstop).
private func setupDevice() {
#if os(iOS)
guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else {
log.info("rumble: this device has no haptic actuator — device rumble unavailable")
broken = true
reportHealth("This device has no haptic actuator.")
return
}
do {
low = startMotor(try CHHapticEngine(), sharpness: RumbleTuning.sharpnessCombined)
} catch {
log.warning("rumble: device haptic engine creation failed: \(error, privacy: .public)")
}
if low == nil {
// Same shape as the controller path: haptics exist but the engine couldn't be built
// right now back off and retry, don't latch off.
scheduleRetryBackoff()
}
#else
broken = true
#endif
}
private func makeMotor( private func makeMotor(
_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality, sharpness: Float _ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality, sharpness: Float
) -> Motor? { ) -> Motor? {
guard let engine = haptics.createEngine(withLocality: locality) else { return nil } guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
return startMotor(engine, sharpness: sharpness)
}
/// Configure + start an engine (controller-locality or the device's own) into a [`Motor`].
private func startMotor(_ engine: CHHapticEngine, sharpness: Float) -> Motor? {
// A controller's motors carry no audio, so keep this engine OUT of the app's audio session // A controller's motors carry no audio, so keep this engine OUT of the app's audio session
// (the default is to join it). Streaming keeps an AVAudioSession active the whole time; // (the default is to join it). Streaming keeps an AVAudioSession active the whole time;
// letting a haptics-only engine join it is a needless coupling that can get its // letting a haptics-only engine join it is a needless coupling that can get its
@@ -546,7 +593,7 @@ final class RumbleRenderer: @unchecked Sendable {
try engine.start() try engine.start()
return Motor(engine: engine, sharpness: sharpness) return Motor(engine: engine, sharpness: sharpness)
} catch { } catch {
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)") log.warning("haptic engine setup failed: \(error, privacy: .public)")
return nil return nil
} }
} }
@@ -97,6 +97,12 @@ public enum DefaultsKey {
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library) /// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`. /// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled" public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
/// iPhone: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
/// device's own Taptic Engine for phone-clip pads that ship without rumble motors, where
/// the phone body is the only actuator in the player's hands. Off by default (opt-in); read
/// once per session by `GamepadFeedback`. The toggle is shown only where the device actually
/// has a haptic actuator (no iPad/Mac/TV).
public static let rumbleOnDevice = "punktfunk.rumbleOnDevice"
/// Auto-wake on connect: when connecting to a saved host that isn't advertising on mDNS, fire /// Auto-wake on connect: when connecting to a saved host that isn't advertising on mDNS, fire
/// Wake-on-LAN and, if the dial fails, wait for it to come back before retrying (the "Waking" /// Wake-on-LAN and, if the dial fails, wait for it to come back before retrying (the "Waking"
/// overlay). On by default. Turn off if a host that's already on just isn't seen on mDNS (a /// overlay). On by default. Turn off if a host that's already on just isn't seen on mDNS (a