diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue
index 1d5e535..5316441 100644
--- a/frontend/src/components/Header.vue
+++ b/frontend/src/components/Header.vue
@@ -48,6 +48,13 @@
@focus="handleSearchFocus"
@keydown.escape="handleEscape"
/>
+
@@ -146,6 +153,7 @@ import { useRouter } from "vue-router"
import { navAttrs } from "../composables/useKeyboardNavigation"
import logoUrl from "../assets/mediahive.webp"
import { fetchRoots, replaceRoots, pickFolderAndAddRoot } from "../api"
+import HexKeyboard from "./HexKeyboard.vue"
interface RootEntry {
root_id: string
@@ -285,11 +293,34 @@ watch(
)
function handleEscape() {
+ if (hexKeyboardVisible.value) {
+ hexKeyboardVisible.value = false
+ return
+ }
// Clear search and blur
localSearch.value = ""
searchInputRef.value?.blur()
}
+const hexKeyboardVisible = ref(false)
+
+function onGamepadAction(event: Event) {
+ const customEvent = event as CustomEvent<{ action?: string }>
+ const action = customEvent.detail?.action
+ if (!action) return
+
+ // Only handle when search input is focused
+ const active = document.activeElement
+ if (!active || !searchInputRef.value || active !== searchInputRef.value) return
+
+ if (action === "select") {
+ event.preventDefault()
+ if (!hexKeyboardVisible.value) {
+ hexKeyboardVisible.value = true
+ }
+ }
+}
+
function focusSearchInput() {
searchInputRef.value?.focus()
searchInputRef.value?.select()
@@ -316,10 +347,12 @@ function handleKeydown(e: KeyboardEvent) {
onMounted(() => {
window.addEventListener("keydown", handleKeydown)
+ window.addEventListener("mediahive:gamepad-action", onGamepadAction)
})
onUnmounted(() => {
window.removeEventListener("keydown", handleKeydown)
+ window.removeEventListener("mediahive:gamepad-action", onGamepadAction)
})
diff --git a/frontend/src/components/HexKeyboard.vue b/frontend/src/components/HexKeyboard.vue
new file mode 100644
index 0000000..e9582c2
--- /dev/null
+++ b/frontend/src/components/HexKeyboard.vue
@@ -0,0 +1,626 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/composables/useGamepadNavigation.ts b/frontend/src/composables/useGamepadNavigation.ts
index 69826f1..1a21a4b 100644
--- a/frontend/src/composables/useGamepadNavigation.ts
+++ b/frontend/src/composables/useGamepadNavigation.ts
@@ -1,7 +1,8 @@
type GamepadAction = "up" | "down" | "left" | "right" | "select" | "back" | "menu"
const GAMEPAD_AXIS_THRESHOLD = 0.55
-const GAMEPAD_REPEAT_MS = 180
+const GAMEPAD_REPEAT_MS = 90
+const GAMEPAD_VERTICAL_REPEAT_MS = 140
const KEY_BY_ACTION: Partial
> = {
up: "ArrowUp",
@@ -32,6 +33,24 @@ const gamepadLastTriggerAt: Record = {
menu: 0,
}
+const rawButtonLastTriggerAt: Record = {
+ 2: 0,
+ 3: 0,
+ 4: 0,
+ 5: 0,
+}
+
+function applyRawButtonEvent(button: number, now: number) {
+ const canTrigger = now - (rawButtonLastTriggerAt[button] ?? 0) >= GAMEPAD_REPEAT_MS
+ if (!canTrigger) return
+ rawButtonLastTriggerAt[button] = now
+ const event = new CustomEvent("mediahive:gamepad-button", {
+ detail: { button },
+ cancelable: true,
+ })
+ window.dispatchEvent(event)
+}
+
let gamepadFrameId: number | null = null
let gamepadInstalled = false
@@ -53,10 +72,12 @@ function applyGamepadAction(action: GamepadAction, isPressed: boolean, now: numb
if (!isPressed) return
- const shouldRepeat =
- action === "up" || action === "down" || action === "left" || action === "right"
+ const isVertical = action === "up" || action === "down"
+ const isHorizontal = action === "left" || action === "right"
+ const shouldRepeat = isVertical || isHorizontal
+ const repeatMs = isVertical ? GAMEPAD_VERTICAL_REPEAT_MS : GAMEPAD_REPEAT_MS
const canTrigger =
- !wasPressed || (shouldRepeat && now - gamepadLastTriggerAt[action] >= GAMEPAD_REPEAT_MS)
+ !wasPressed || (shouldRepeat && now - gamepadLastTriggerAt[action] >= repeatMs)
if (!canTrigger) return
gamepadLastTriggerAt[action] = now
@@ -106,11 +127,30 @@ function pollGamepad() {
left = left || Boolean(gamepad.buttons[14]?.pressed) || axisX <= -GAMEPAD_AXIS_THRESHOLD
right = right || Boolean(gamepad.buttons[15]?.pressed) || axisX >= GAMEPAD_AXIS_THRESHOLD
- // Xbox mapping on standard gamepads: A=0, B=1
+ // Xbox mapping on standard gamepads: A=0, B=1, X=2, Y=3
select = select || Boolean(gamepad.buttons[0]?.pressed)
back = back || Boolean(gamepad.buttons[1]?.pressed)
- // Y/Triangle button opens contextual release list where supported.
+ // X/Square = backspace (handled by raw button event)
+ // Y/Triangle = space (handled by raw button event)
menu = menu || Boolean(gamepad.buttons[3]?.pressed)
+
+ if (gamepad.buttons[2]?.pressed) {
+ applyRawButtonEvent(2, now)
+ }
+ if (gamepad.buttons[3]?.pressed) {
+ applyRawButtonEvent(3, now)
+ }
+
+ // Shoulder buttons for cursor movement (handled by raw button event)
+ const lb = Boolean(gamepad.buttons[4]?.pressed)
+ const rb = Boolean(gamepad.buttons[5]?.pressed)
+
+ if (lb) {
+ applyRawButtonEvent(4, now)
+ }
+ if (rb) {
+ applyRawButtonEvent(5, now)
+ }
}
applyGamepadAction("up", up, now)