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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user