Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/src/main/java/com/limelight/KeyboardInputHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import android.view.KeyEvent
import android.widget.Toast
import com.limelight.binding.input.ControllerHandler
import com.limelight.binding.input.KeyboardTranslator
import com.limelight.binding.input.HardwareKeyMappingStore
import com.limelight.nvstream.input.KeyboardPacket
import com.limelight.nvstream.input.MouseButtonPacket
import com.limelight.nvstream.jni.MoonBridge
Expand Down Expand Up @@ -130,6 +131,10 @@ class KeyboardInputHandler(private val game: Game) {
}

fun handleKeyDown(event: KeyEvent): Boolean {
return handleKeyDownMapped(HardwareKeyMappingStore.remap(game, event))
}

private fun handleKeyDownMapped(event: KeyEvent): Boolean {
when (event.keyCode) {
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_HOME, KeyEvent.KEYCODE_APP_SWITCH,
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN,
Expand Down Expand Up @@ -302,6 +307,10 @@ class KeyboardInputHandler(private val game: Game) {
}

fun handleKeyUp(event: KeyEvent): Boolean {
return handleKeyUpMapped(HardwareKeyMappingStore.remap(game, event))
}

private fun handleKeyUpMapped(event: KeyEvent): Boolean {
when (event.keyCode) {
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_HOME, KeyEvent.KEYCODE_APP_SWITCH -> {
// 系统导航键直接跳过去重逻辑
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package com.limelight.binding.input

import android.content.Context
import android.view.InputDevice
import android.view.KeyEvent
import org.json.JSONArray
import org.json.JSONObject

data class HardwareKeyMapping(
val deviceKey: String,
val deviceName: String,
val sourceScanCode: Int,
val sourceKeyCode: Int,
val targetKeyCode: Int
) {
fun matches(event: KeyEvent): Boolean {
if (deviceKey != HardwareKeyMappingStore.deviceKey(event.device)) return false
return if (sourceScanCode > 0) {
event.scanCode == sourceScanCode
} else {
event.keyCode == sourceKeyCode
}
}
}

object HardwareKeyMappingStore {
private const val PREFS_NAME = "hardware_key_mappings"
private const val KEY_MAPPINGS = "mappings_v1"

fun mappingFrom(event: KeyEvent, targetKeyCode: Int): HardwareKeyMapping {
val device = event.device
return HardwareKeyMapping(
deviceKey = deviceKey(device),
deviceName = device?.name ?: "Unknown keyboard",
sourceScanCode = event.scanCode,
sourceKeyCode = event.keyCode,
targetKeyCode = targetKeyCode
)
}

fun resolve(context: Context, event: KeyEvent): Int {
return load(context).firstOrNull { it.matches(event) }?.targetKeyCode ?: event.keyCode
}

fun remap(context: Context, event: KeyEvent): KeyEvent {
val targetKeyCode = resolve(context, event)
if (targetKeyCode == event.keyCode) return event
return KeyEvent(
event.downTime,
event.eventTime,
event.action,
targetKeyCode,
event.repeatCount,
event.metaState,
event.deviceId,
event.scanCode,
event.flags,
event.source
)
}

fun save(context: Context, mapping: HardwareKeyMapping) {
val mappings = load(context).toMutableList()
mappings.removeAll {
it.deviceKey == mapping.deviceKey &&
if (mapping.sourceScanCode > 0) {
it.sourceScanCode == mapping.sourceScanCode
} else {
it.sourceScanCode <= 0 && it.sourceKeyCode == mapping.sourceKeyCode
}
}
mappings.add(mapping)
write(context, mappings)
}

fun remove(context: Context, mapping: HardwareKeyMapping) {
write(context, load(context).filterNot { it == mapping })
}

fun clear(context: Context) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.remove(KEY_MAPPINGS)
.apply()
}

fun load(context: Context): List<HardwareKeyMapping> {
val json = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(KEY_MAPPINGS, null) ?: return emptyList()
return decode(json)
}
Comment on lines +115 to +119

internal fun encode(mappings: List<HardwareKeyMapping>): String {
val array = JSONArray()
mappings.forEach { mapping ->
array.put(JSONObject().apply {
put("deviceKey", mapping.deviceKey)
put("deviceName", mapping.deviceName)
put("sourceScanCode", mapping.sourceScanCode)
put("sourceKeyCode", mapping.sourceKeyCode)
put("targetKeyCode", mapping.targetKeyCode)
})
}
return array.toString()
}

internal fun decode(json: String): List<HardwareKeyMapping> {
return try {
val array = JSONArray(json)
buildList {
for (index in 0 until array.length()) {
val item = array.optJSONObject(index) ?: continue
val target = item.optInt("targetKeyCode", KeyEvent.KEYCODE_UNKNOWN)
if (target == KeyEvent.KEYCODE_UNKNOWN) continue
add(
HardwareKeyMapping(
deviceKey = item.optString("deviceKey"),
deviceName = item.optString("deviceName", "Unknown keyboard"),
sourceScanCode = item.optInt("sourceScanCode"),
sourceKeyCode = item.optInt("sourceKeyCode"),
targetKeyCode = target
)
)
Comment on lines +140 to +160
}
}
} catch (_: Exception) {
emptyList()
}
}

internal fun deviceKey(device: InputDevice?): String {
if (device == null) return "unknown"
return if (device.vendorId != 0 || device.productId != 0) {
"%04x:%04x".format(device.vendorId, device.productId)
} else {
"name:${device.name}"
}
}

private fun write(context: Context, mappings: List<HardwareKeyMapping>) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putString(KEY_MAPPINGS, encode(mappings))
.apply()
}
}
156 changes: 155 additions & 1 deletion app/src/main/java/com/limelight/preferences/StreamSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ import com.limelight.R
import com.limelight.ExternalDisplayManager
import com.limelight.binding.input.advance_setting.config.PageConfigController
import com.limelight.binding.input.advance_setting.sqlite.SuperConfigDatabaseHelper
import com.limelight.binding.input.HardwareKeyMapping
import com.limelight.binding.input.HardwareKeyMappingStore
import com.limelight.binding.video.MediaCodecHelper
import com.limelight.services.KeyboardAccessibilityService
import com.limelight.utils.AspectRatioConverter
import com.limelight.utils.ConfigurationSyncManager
import com.limelight.utils.ConfigurationSyncScheduler
Expand All @@ -92,7 +95,7 @@ import kotlin.concurrent.thread
import kotlin.math.roundToInt
import kotlin.math.sqrt

class StreamSettings : AppCompatActivity() {
class StreamSettings : AppCompatActivity(), KeyboardAccessibilityService.KeyEventCallback {

private lateinit var previousPrefs: PreferenceConfiguration
private var previousDisplayPixelCount = 0
Expand All @@ -110,6 +113,8 @@ class StreamSettings : AppCompatActivity() {
private var searchInput: EditText? = null
private var searchToggle: ImageView? = null
private var menuToggleView: ImageView? = null
private var hardwareKeyCapture: ((KeyEvent) -> Unit)? = null
private var hardwareKeyCaptureDialog: AlertDialog? = null

// 状态保存键
companion object {
Expand Down Expand Up @@ -208,6 +213,149 @@ class StreamSettings : AppCompatActivity() {
setupVersionInfo()
}

override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val capture = hardwareKeyCapture
if (capture != null && event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
capture(event)
return true
}
return super.dispatchKeyEvent(event)
}

override fun onKeyEvent(event: KeyEvent) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
runOnUiThread { hardwareKeyCapture?.invoke(event) }
}
}

override fun onPause() {
stopHardwareKeyCapture()
super.onPause()
}

private fun stopHardwareKeyCapture() {
hardwareKeyCapture = null
hardwareKeyCaptureDialog?.dismiss()
hardwareKeyCaptureDialog = null
KeyboardAccessibilityService.captureAllKeys = false
KeyboardAccessibilityService.setIntercepting(false)
if (KeyboardAccessibilityService.instance?.keyEventCallback === this) {
KeyboardAccessibilityService.instance?.keyEventCallback = null
}
}

fun showHardwareKeyMappings() {
val mappings = HardwareKeyMappingStore.load(this)
val labels = mappings.map(::mappingLabel).toMutableList()
labels.add(getString(R.string.hardware_key_mapping_add))
if (mappings.isNotEmpty()) labels.add(getString(R.string.hardware_key_mapping_clear))

AlertDialog.Builder(this)
.setTitle(R.string.title_hardware_key_mappings)
.setItems(labels.toTypedArray()) { _, index ->
when {
index < mappings.size -> confirmDeleteMapping(mappings[index])
index == mappings.size -> beginHardwareKeyCapture()
else -> {
HardwareKeyMappingStore.clear(this)
showHardwareKeyMappings()
}
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
Comment on lines +247 to +275

private fun beginHardwareKeyCapture() {
val service = KeyboardAccessibilityService.instance
val message = if (service == null) {
getString(R.string.hardware_key_mapping_press_source) + "\n\n" +
getString(R.string.hardware_key_mapping_accessibility_hint)
} else {
getString(R.string.hardware_key_mapping_press_source)
}

val dialog = AlertDialog.Builder(this)
.setTitle(R.string.hardware_key_mapping_add)
.setMessage(message)
.setNegativeButton(android.R.string.cancel) { _, _ -> stopHardwareKeyCapture() }
.create()
hardwareKeyCaptureDialog = dialog
hardwareKeyCapture = { event ->
hardwareKeyCapture = null
hardwareKeyCaptureDialog = null
dialog.dismiss()
KeyboardAccessibilityService.captureAllKeys = false
KeyboardAccessibilityService.setIntercepting(false)
service?.keyEventCallback = null
showHardwareKeyTargetPicker(event)
}
if (service != null) {
service.keyEventCallback = this
KeyboardAccessibilityService.captureAllKeys = true
KeyboardAccessibilityService.setIntercepting(true)
}
dialog.setOnDismissListener {
if (hardwareKeyCapture != null) stopHardwareKeyCapture()
}
dialog.show()
}

private fun showHardwareKeyTargetPicker(sourceEvent: KeyEvent) {
val targets = buildList {
for (keyCode in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12) add(keyCode)
addAll(
listOf(
KeyEvent.KEYCODE_ESCAPE,
KeyEvent.KEYCODE_TAB,
KeyEvent.KEYCODE_INSERT,
KeyEvent.KEYCODE_FORWARD_DEL,
KeyEvent.KEYCODE_MOVE_HOME,
KeyEvent.KEYCODE_MOVE_END,
KeyEvent.KEYCODE_PAGE_UP,
KeyEvent.KEYCODE_PAGE_DOWN,
KeyEvent.KEYCODE_SYSRQ,
KeyEvent.KEYCODE_BREAK
)
)
}
val labels = targets.map { keyCodeLabel(it) }.toTypedArray()
AlertDialog.Builder(this)
.setTitle(R.string.hardware_key_mapping_choose_target)
.setItems(labels) { _, index ->
HardwareKeyMappingStore.save(
this,
HardwareKeyMappingStore.mappingFrom(sourceEvent, targets[index])
)
Toast.makeText(this, R.string.hardware_key_mapping_saved, Toast.LENGTH_SHORT).show()
showHardwareKeyMappings()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}

private fun confirmDeleteMapping(mapping: HardwareKeyMapping) {
AlertDialog.Builder(this)
.setTitle(mappingLabel(mapping))
.setMessage(R.string.hardware_key_mapping_delete)
.setPositiveButton(android.R.string.ok) { _, _ ->
HardwareKeyMappingStore.remove(this, mapping)
showHardwareKeyMappings()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}

private fun mappingLabel(mapping: HardwareKeyMapping): String {
val source = KeyEvent.keyCodeToString(mapping.sourceKeyCode).removePrefix("KEYCODE_")
return "${mapping.deviceName}: $source [${mapping.sourceScanCode}] -> ${keyCodeLabel(mapping.targetKeyCode)}"
}

private fun keyCodeLabel(keyCode: Int): String {
return com.limelight.utils.KeyCodeMapper.getDisplayName(keyCode)
?: KeyEvent.keyCodeToString(keyCode).removePrefix("KEYCODE_")
}

/**
* 设置版本号显示
*/
Expand Down Expand Up @@ -2560,6 +2708,12 @@ class StreamSettings : AppCompatActivity() {
true
}

findPreference<Preference>("hardware_key_mappings")!!.onPreferenceClickListener =
Preference.OnPreferenceClickListener {
(requireActivity() as StreamSettings).showHardwareKeyMappings()
true
}

addCustomResolutionsEntries()

// 添加检查更新选项的点击事件
Expand Down
Loading
Loading