Эх сурвалжийг харах

fix(web): 修复手柄功能的生命周期与状态同步问题

- 新增 disposeXboxGamepad(),页面卸载时停止 rAF 轮询并移除事件监听,
  避免离开阅读页后仍持续 60fps 轮询耗电、误触发
- 无手柄连接时自动停止轮询,等 gamepadconnected 再启动
- B 键切换目录改为经 Vue 状态回调控制 popCataVisible,
  不再直接操作 DOM 绕过 Element Plus,避免弹层状态不同步
- 进入全屏的 promise 捕获拒绝(手柄轮询无用户激活,可能被浏览器拒绝)
- 按键/摇杆边沿状态按手柄 index 分键,避免多手柄串触发
- 不支持 getGamepads 的环境停止循环
HapeLee 3 сар өмнө
parent
commit
1a3422ce9b

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
app/src/main/assets/web/vue/assets/BookChapter-C4w1HiAZ.css


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
app/src/main/assets/web/vue/assets/BookChapter-CfCqElUj.css


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
app/src/main/assets/web/vue/assets/BookChapter-D7LUqZ_c.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
app/src/main/assets/web/vue/assets/BookChapter-D89_Hqjl.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
app/src/main/assets/web/vue/assets/BookShelf-00b2QCsd.css


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
app/src/main/assets/web/vue/assets/BookShelf-Brh2uMWa.css


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
app/src/main/assets/web/vue/assets/BookShelf-C2XtfcB5.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 1
app/src/main/assets/web/vue/assets/index-B0-kopLm.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
app/src/main/assets/web/vue/assets/index-CpkaNQMA.css


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
app/src/main/assets/web/vue/assets/index-CrxHVQK7.css


+ 2 - 2
app/src/main/assets/web/vue/index.html

@@ -4,10 +4,10 @@
         <meta charset="UTF-8" />
         <link rel="icon" href="./favicon.ico" />
     <meta name="viewport" content="width=device-width,initial-scale=1.0" />
-      <script type="module" crossorigin src="./assets/index-DRxGTcTS.js"></script>
+      <script type="module" crossorigin src="./assets/index-B0-kopLm.js"></script>
       <link rel="modulepreload" crossorigin href="./assets/vendor-DQqeohSF.js">
       <link rel="stylesheet" crossorigin href="./assets/vendor-CXe1BRiH.css">
-      <link rel="stylesheet" crossorigin href="./assets/index-CrxHVQK7.css">
+      <link rel="stylesheet" crossorigin href="./assets/index-CpkaNQMA.css">
     </head>
 
     <body>

+ 147 - 67
modules/web/src/utils/xboxGamepad.ts

@@ -30,12 +30,32 @@ interface DPadState {
   b: boolean
 }
 
-interface GlobalState {
+interface GamepadState {
   lastAxisTime: number
   lastAxisDirection: number
   dpadPressed: DPadState
 }
 
+export interface XboxGamepadOptions {
+  /** B 键切换目录:由调用方经 Vue 状态控制,避免直接操作 DOM */
+  onToggleCatalog?: () => void
+}
+
+function createGamepadState(): GamepadState {
+  return {
+    lastAxisTime: 0,
+    lastAxisDirection: 0,
+    dpadPressed: {
+      up: false,
+      down: false,
+      left: false,
+      right: false,
+      a: false,
+      b: false,
+    },
+  }
+}
+
 // =====================================================
 // 配置区域
 // =====================================================
@@ -75,25 +95,14 @@ function log(...args: any[]): void {
 // 状态记录
 // =====================================================
 
-const state: GlobalState = {
-  lastAxisTime: 0,
-
-  lastAxisDirection: 0,
-
-  dpadPressed: {
-    up: false,
-
-    down: false,
-
-    left: false,
+let running = false
 
-    right: false,
+let loopId: number | null = null
 
-    a: false,
+let onToggleCatalog: (() => void) | null = null
 
-    b: false,
-  },
-}
+// 每个手柄独立一份按键/摇杆边沿状态,避免多手柄互相串触发
+const gamepadStates = new Map<number, GamepadState>()
 
 // =====================================================
 // 通用工具函数
@@ -164,7 +173,10 @@ function switchChapter(direction: number): void {
  */
 function toggleFullscreen(): void {
   if (!document.fullscreenElement) {
-    document.documentElement.requestFullscreen?.()
+    const promise = document.documentElement.requestFullscreen?.()
+
+    // 手柄轮询触发通常没有用户激活,可能被浏览器拒绝,需要捕获
+    promise?.catch(() => log('🎮 进入浏览器全屏被拒绝'))
 
     log('🎮 进入浏览器全屏')
   } else {
@@ -178,25 +190,15 @@ function toggleFullscreen(): void {
  * 目录显示隐藏
  */
 function toggleCatalog(): void {
-  const catalog = document.querySelector('.pop-cata') as HTMLElement
+  if (onToggleCatalog) {
+    onToggleCatalog()
 
-  if (!catalog) {
-    log('❌ 未找到目录')
+    log('🎮 切换目录')
 
     return
   }
 
-  const hidden = getComputedStyle(catalog).display === 'none'
-
-  if (hidden) {
-    catalog.style.display = 'block'
-
-    log('🎮 打开目录')
-  } else {
-    catalog.style.display = 'none'
-
-    log('🎮 关闭目录')
-  }
+  log('❌ 未注册目录切换回调')
 }
 
 /**
@@ -210,25 +212,25 @@ function isPressedOnce(current: boolean, previous: boolean): boolean {
 // 摇杆处理
 // =====================================================
 
-function handleAxis(gp: Gamepad, now: number): void {
+function handleAxis(gp: Gamepad, now: number, gs: GamepadState): void {
   const axisY = gp.axes[1] || 0
 
   if (Math.abs(axisY) <= CONFIG.AXIS_THRESHOLD) {
-    state.lastAxisDirection = 0
+    gs.lastAxisDirection = 0
 
     return
   }
 
   const direction = axisY > 0 ? 1 : -1
 
-  const cooldownPassed = now - state.lastAxisTime > CONFIG.AXIS_COOLDOWN
+  const cooldownPassed = now - gs.lastAxisTime > CONFIG.AXIS_COOLDOWN
 
-  const directionChanged = direction !== state.lastAxisDirection
+  const directionChanged = direction !== gs.lastAxisDirection
 
   if (cooldownPassed || directionChanged) {
-    state.lastAxisTime = now
+    gs.lastAxisTime = now
 
-    state.lastAxisDirection = direction
+    gs.lastAxisDirection = direction
 
     scrollPage(direction)
   }
@@ -238,7 +240,7 @@ function handleAxis(gp: Gamepad, now: number): void {
 // DPad + 按键处理
 // =====================================================
 
-function handleDPad(gp: Gamepad): void {
+function handleDPad(gp: Gamepad, gs: GamepadState): void {
   const indexes = CONFIG.DPAD_INDEX
 
   const buttons = CONFIG.BUTTON_INDEX
@@ -259,97 +261,175 @@ function handleDPad(gp: Gamepad): void {
 
   // 十字 ↑ 顶部
 
-  if (isPressedOnce(current.up, state.dpadPressed.up)) {
+  if (isPressedOnce(current.up, gs.dpadPressed.up)) {
     goTop()
   }
 
   // 十字 ↓ 底部
 
-  if (isPressedOnce(current.down, state.dpadPressed.down)) {
+  if (isPressedOnce(current.down, gs.dpadPressed.down)) {
     goBottom()
   }
 
   // 左右章节
 
-  if (isPressedOnce(current.left, state.dpadPressed.left)) {
+  if (isPressedOnce(current.left, gs.dpadPressed.left)) {
     switchChapter(-1)
   }
 
-  if (isPressedOnce(current.right, state.dpadPressed.right)) {
+  if (isPressedOnce(current.right, gs.dpadPressed.right)) {
     switchChapter(1)
   }
 
   // A 全屏
 
-  if (isPressedOnce(current.a, state.dpadPressed.a)) {
+  if (isPressedOnce(current.a, gs.dpadPressed.a)) {
     toggleFullscreen()
   }
 
   // B 目录
 
-  if (isPressedOnce(current.b, state.dpadPressed.b)) {
+  if (isPressedOnce(current.b, gs.dpadPressed.b)) {
     toggleCatalog()
   }
 
-  state.dpadPressed = current
+  gs.dpadPressed = current
 }
 
 // =====================================================
 // 主手柄处理入口
 // =====================================================
 
-function handleGamepad(gp: Gamepad | null): void {
-  if (!gp) return
+function handleGamepad(gp: Gamepad): void {
+  let gs = gamepadStates.get(gp.index)
+
+  if (!gs) {
+    gs = createGamepadState()
+
+    gamepadStates.set(gp.index, gs)
+  }
 
   const now = performance.now()
 
-  handleAxis(gp, now)
+  handleAxis(gp, now, gs)
 
-  handleDPad(gp)
+  handleDPad(gp, gs)
 }
 
 // =====================================================
 // 主循环
 // =====================================================
 
-let running = false
+function stopLoop(): void {
+  running = false
+
+  if (loopId !== null) {
+    cancelAnimationFrame(loopId)
+
+    loopId = null
+  }
+}
+
+function startLoop(): void {
+  if (running) return
+
+  running = true
+
+  loopId = requestAnimationFrame(gamepadLoop)
+}
+
+function hasConnectedGamepad(): boolean {
+  if (typeof navigator.getGamepads !== 'function') return false
+
+  const gamepads = navigator.getGamepads()
+
+  for (const gp of gamepads) {
+    if (gp) return true
+  }
+
+  return false
+}
 
 function gamepadLoop(): void {
-  const gamepads = navigator.getGamepads?.() || []
+  // 不支持 getGamepads 的环境直接停止轮询
+  if (typeof navigator.getGamepads !== 'function') {
+    stopLoop()
+
+    return
+  }
+
+  const gamepads = navigator.getGamepads()
 
   for (const gp of gamepads) {
     if (gp) handleGamepad(gp)
   }
 
-  requestAnimationFrame(gamepadLoop)
+  // 没有已连接手柄时停止 60fps 轮询,等下次 gamepadconnected 再启动
+  if (hasConnectedGamepad()) {
+    loopId = requestAnimationFrame(gamepadLoop)
+  } else {
+    stopLoop()
+  }
 }
 
 // =====================================================
 // 连接 / 断开事件
 // =====================================================
 
-window.addEventListener('gamepadconnected', (e: GamepadEvent) => {
-  log('🎮 手柄已连接:', e.gamepad.id)
+let listenersRegistered = false
 
-  if (!running) {
-    running = true
+function onGamepadConnected(e: GamepadEvent): void {
+  log('🎮 手柄已连接:', e.gamepad.id)
 
-    requestAnimationFrame(gamepadLoop)
-  }
-})
+  startLoop()
+}
 
-window.addEventListener('gamepaddisconnected', (e: GamepadEvent) => {
+function onGamepadDisconnected(e: GamepadEvent): void {
   log('🎮 手柄断开:', e.gamepad.id)
-})
+
+  gamepadStates.delete(e.gamepad.index)
+
+  if (!hasConnectedGamepad()) stopLoop()
+}
+
+function registerListeners(): void {
+  if (listenersRegistered) return
+
+  window.addEventListener('gamepadconnected', onGamepadConnected)
+
+  window.addEventListener('gamepaddisconnected', onGamepadDisconnected)
+
+  listenersRegistered = true
+}
+
+function unregisterListeners(): void {
+  if (!listenersRegistered) return
+
+  window.removeEventListener('gamepadconnected', onGamepadConnected)
+
+  window.removeEventListener('gamepaddisconnected', onGamepadDisconnected)
+
+  listenersRegistered = false
+}
 
 // =====================================================
 // 外部调用
 // =====================================================
 
-export function initXboxGamepad(): void {
-  if (!running) {
-    running = true
+export function initXboxGamepad(options?: XboxGamepadOptions): void {
+  onToggleCatalog = options?.onToggleCatalog ?? null
 
-    requestAnimationFrame(gamepadLoop)
-  }
+  registerListeners()
+
+  startLoop()
+}
+
+export function disposeXboxGamepad(): void {
+  stopLoop()
+
+  unregisterListeners()
+
+  gamepadStates.clear()
+
+  onToggleCatalog = null
 }

+ 8 - 2
modules/web/src/views/BookChapter.vue

@@ -113,7 +113,7 @@ import API from '@api'
 import { useLoading } from '@/hooks/loading'
 import { useThrottleFn } from '@vueuse/shared'
 import { isNullOrBlank } from '@/utils/utils'
-import { initXboxGamepad } from '@/utils/xboxGamepad'
+import { disposeXboxGamepad, initXboxGamepad } from '@/utils/xboxGamepad'
 
 const content = ref()
 // loading spinner
@@ -520,7 +520,11 @@ onMounted(async () => {
       document.title = (name as string) + ' | ' + chapters[chapterIndex].title
     }),
   )
-  initXboxGamepad()
+  initXboxGamepad({
+    onToggleCatalog: () => {
+      popCataVisible.value = !popCataVisible.value
+    },
+  })
 })
 
 onUnmounted(() => {
@@ -533,6 +537,8 @@ onUnmounted(() => {
   popCataVisible.value = false
   scrollObserver?.disconnect()
   scrollObserver = null
+
+  disposeXboxGamepad()
 })
 
 const addToBookShelfConfirm = async () => {

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно