From d58524c8992d2b81bb84c56e7b41704dcbe8d860 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 14 Jul 2026 16:32:19 +0200 Subject: [PATCH] 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 --- .../unom/punktfunk/GamepadSettingsScreen.kt | 29 +++++++-- .../main/kotlin/io/unom/punktfunk/Settings.kt | 11 ++++ .../io/unom/punktfunk/SettingsScreen.kt | 13 ++++ .../kotlin/io/unom/punktfunk/StreamScreen.kt | 10 +++- .../io/unom/punktfunk/kit/GamepadFeedback.kt | 60 ++++++++++++++++++- .../Settings/GamepadSettingsView.swift | 25 +++++++- .../Settings/SettingsView+Sections.swift | 14 +++++ .../Settings/SettingsView+Support.swift | 7 +++ .../Settings/SettingsView.swift | 1 + .../Gamepad/GamepadFeedback.swift | 23 +++++++ .../PunktfunkKit/Gamepad/RumbleRenderer.swift | 51 +++++++++++++++- .../PunktfunkKit/Support/DefaultsKeys.swift | 6 ++ 12 files changed, 239 insertions(+), 11 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index a21247e2..38ba4e71 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -49,12 +49,14 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.chrisbanes.haze.HazeState 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 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) } 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) } if (focus > rows.lastIndex) focus = rows.lastIndex // 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]. */ -private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List { +/** Build the console settings rows from the current [Settings], writing through [update]. + * [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs). */ +private fun buildSettingsRows( + s: Settings, + hasBodyVibrator: Boolean, + update: (Settings) -> Unit, +): List { fun choice( id: String, header: String?, label: String, detail: String, options: List>, current: T, write: (T) -> Unit, @@ -354,7 +364,18 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List i to lbl }, s.gamepad, ) { 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( "hud", "Interface", "Statistics overlay", "How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " + diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index 2fc8fc9c..62ee79a1 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -82,6 +82,14 @@ data class Settings( * otherwise misfire and wait out its timeout despite the host already being reachable. */ 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. */ @@ -142,6 +150,7 @@ class SettingsStore(context: Context) { libraryEnabled = prefs.getBoolean(K_LIBRARY, true), lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true), autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true), + rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false), ) fun save(s: Settings) { @@ -162,6 +171,7 @@ class SettingsStore(context: Context) { .putBoolean(K_LIBRARY, s.libraryEnabled) .putBoolean(K_LOW_LATENCY, s.lowLatencyMode) .putBoolean(K_AUTO_WAKE, s.autoWakeEnabled) + .putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone) .apply() } @@ -197,6 +207,7 @@ class SettingsStore(context: Context) { */ const val K_LOW_LATENCY = "low_latency_mode_v2" 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. */ const val K_TRACKPAD = "trackpad_mode" diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index 45bc43f3..d15021a6 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -69,6 +69,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat 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 @@ -414,6 +415,18 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont subtitle = "What the app detects, with a live input test", 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)) }, + ) + } } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 7e34de9f..e2fd44b1 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -41,6 +41,7 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import io.unom.punktfunk.kit.GamepadFeedback import io.unom.punktfunk.kit.GamepadRouter +import io.unom.punktfunk.kit.deviceBodyVibrator import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.VideoDecoders 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 // 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 - // session closed. - val feedback = GamepadFeedback(handle, router).also { it.start() } + // session closed. "Rumble on this phone" (opt-in) additionally mirrors controller 1's + // 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 // session leaks until the session ends). The router owns hot-plug; the feedback owns the binds. router.onSlotClosed = feedback::onDeviceRemoved diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt index 76583034..0741c8bf 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt @@ -1,5 +1,6 @@ package io.unom.punktfunk.kit +import android.content.Context import android.graphics.Color import android.hardware.lights.Light 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 * 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 { const val TAG = "pf.feedback" const val TAG_LED: Byte = 0x01 @@ -127,7 +138,9 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute runCatching { hidoutThread?.join() } rumbleThread = 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) { for (b in rumbleBinds.values) b?.let { 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) { 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 lo = toAmplitude(low) 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. private fun toAmplitude(v16: Int): Int { 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() } +} diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 80a8bb48..e306fc68 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -15,6 +15,9 @@ import PunktfunkKit import SwiftUI #if os(iOS) || os(macOS) || os(tvOS) import GameController +#if os(iOS) +import CoreHaptics +#endif struct GamepadSettingsView: View { @Environment(\.dismiss) private var dismiss @@ -38,6 +41,9 @@ struct GamepadSettingsView: View { @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true @AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true @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 #if os(iOS) @@ -230,7 +236,7 @@ struct GamepadSettingsView: View { .map { (label: "\($0) Hz", tag: $0) } let bitrate = SettingsOptions.bitrateOptions(current: bitrateKbps) let controllers = SettingsOptions.controllerOptions(gamepads) - return [ + var list: [Row] = [ choiceRow( id: "resolution", header: "Stream", icon: "aspectratio", label: "Resolution", @@ -329,6 +335,23 @@ struct GamepadSettingsView: View { detail: "Turn off to use the touch interface even with a controller connected.", 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 diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift index 6d2c10b5..0277579a 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift @@ -1,6 +1,9 @@ // SettingsView's shared sections — each setting's Section is defined exactly once here and // composed by the per-platform bodies in SettingsView.swift. +#if os(iOS) +import CoreHaptics +#endif import PunktfunkKit import SwiftUI @@ -471,6 +474,12 @@ extension SettingsView { 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) Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled) #endif @@ -487,6 +496,11 @@ extension SettingsView { // for its own footer and has no such toggle to describe. VStack(alignment: .leading, spacing: 6) { Text(Self.controllersFooter) + #if os(iOS) + if CHHapticEngine.capabilitiesForHardware().supportsHaptics { + Text(Self.deviceRumbleFooter) + } + #endif #if !os(tvOS) Text(Self.gamepadUIFooter) #endif diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Support.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Support.swift index 72607f52..80c7c1fd 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Support.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Support.swift @@ -88,6 +88,13 @@ extension SettingsView { + "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). " + "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) static let gamepadUIFooter = "When a controller connects, the host list and library switch to a controller-" diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift index 379620ab..86fcce93 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift @@ -55,6 +55,7 @@ struct SettingsView: View { #if os(iOS) @AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true @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. // 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). diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift index 92798f59..3ef2c5c7 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift @@ -20,6 +20,7 @@ // (triggers off, player index unset) and its renderer silenced. import Combine +import CoreHaptics import Foundation import GameController @@ -50,9 +51,26 @@ public final class GamepadFeedback { private let routingLock = NSLock() 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) { self.connection = connection 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 // an implicit strong one — and the subscription (stored on self) never retain-cycles. Task { @MainActor [weak self] in @@ -189,6 +207,7 @@ public final class GamepadFeedback { return r } for r in renderers { r.stop() } + deviceRumble?.stop() // Drop the subscription and every dead pad's cached feedback — a controller change after // teardown must not replay this session's triggers/LEDs. Task { @MainActor in @@ -203,6 +222,10 @@ public final class GamepadFeedback { private func routeRumble(pad: UInt8, low: UInt16, high: UInt16, ttlMs: UInt32) { let renderer = withRouting { rumbleByPad[pad] } 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(_ body: () -> R) -> R { diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift index 3da4af5e..9d3fa2a6 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift @@ -119,8 +119,19 @@ final class RumbleRenderer: @unchecked Sendable { 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 policy: Policy + private let actuator: Actuator /// 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) @@ -198,8 +209,9 @@ final class RumbleRenderer: @unchecked Sendable { ((0, 0), DispatchTime(uptimeNanoseconds: 0)) #endif - init(policy: Policy = .session) { + init(policy: Policy = .session, actuator: Actuator = .controller) { self.policy = policy + self.actuator = actuator } /// `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 /// engine otherwise, driven by whichever amplitude is stronger. private func setup() { + if actuator == .device { + setupDevice() + return + } guard let haptics = controller?.haptics else { // 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 @@ -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( _ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality, sharpness: Float ) -> Motor? { 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 // (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 @@ -546,7 +593,7 @@ final class RumbleRenderer: @unchecked Sendable { try engine.start() return Motor(engine: engine, sharpness: sharpness) } 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 } } diff --git a/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift index 65073f4b..adbc5210 100644 --- a/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift @@ -97,6 +97,12 @@ public enum DefaultsKey { /// layout (the console launcher, gamepad-navigable settings, a coverflow-style library) /// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`. 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 /// 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