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)