starmusic-pc
1/**
2 * 遥控器焦点导航引擎(纯 DOM 实现,不依赖 uni API)
3 *
4 * 用途:电视 / 车机端用遥控器方向键 + 确认键操作 UI。
5 * 本引擎 H5 可直接在逻辑层运行;App 端因逻辑层无 DOM,
6 * 需通过 renderjs 在页面视图层运行(见 pages 各页的 renderjs 接入)。
7 *
8 * 设计要点:
9 * - 可聚焦元素统一用 `data-navid` 标记(页面模板逐个打标记);
10 * - 方向键按下时重新采集可见节点坐标,按"主轴距离 + 侧向偏移惩罚"
11 * 选取最近目标,避免跳到斜对角;
12 * - 顶层固定一个"焦点框"(overlay,pointer-events:none)跟随当前项,
13 * 确认键触发元素 click(可正常触发 Vue 的 @click);
14 * - 触摸仍完全可用:焦点框不拦截事件,点击/滑动不受影响(双输入兼容);
15 * - 捕获阶段监听 keydown 并 preventDefault,接管 WebView 默认焦点行为。
16 */
17
18// 键码映射:兼容 DOM 键码 与 部分 Android WebView 的虚拟键码
19const KEYMAP = {
20 up: [38, 119, 19],
21 down: [40, 120, 20],
22 left: [37, 118, 21],
23 right: [39, 122, 22],
24 enter: [13, 32, 66, 23],
25 back: [8, 4, 461], // 返回(默认交给 uni 的 onBackPress,本引擎仅上报)
26 menu: [82, 93],
27}
28
29/**
30 * 可聚焦节点的选择器。
31 * - [data-navid]:跨端显式约定
32 * - .d-press / .d-press-sm:项目既有的「可点击」语义(App.vue 全局样式定义),
33 * 各页面已完整标注,直接复用即可覆盖全部可操作元素,无需逐个补 data-navid
34 * - button / input / textarea:原生可聚焦控件,本身即交互入口
35 */
36const NAV_SELECTOR = '[data-navid], .d-press, .d-press-sm, button, input, textarea'
37
38/** 输入类标签:方向键需交给系统输入法 */
39const INPUT_TAG = /^(INPUT|TEXTAREA)$/i
40
41class TVFocusEngine {
42 constructor(opts = {}) {
43 this.name = opts.name || 'tv'
44 // 回调(onHint 用于非 DOM 端把按键上报出去)
45 this.onMove = opts.onMove || null
46 this.onEnter = opts.onEnter || null
47 this.onBack = opts.onBack || null
48 this.onMenu = opts.onMenu || null
49
50 this.nodes = [] // 已采集的导航节点 { el, x, y, w, h, id }
51 this.currentEl = null // 当前聚焦元素
52 this.ring = null // 焦点框
53 this.enabled = true
54 this.handler = null
55 this.doc = null
56 this._scopeEl = null // 当前导航作用域(弹层)
57 this._prevFocusEl = null // 进入弹层前的焦点,用于弹层关闭后还原
58 this._highlighted = null // 已高亮节点,避免每次遍历全部节点
59 }
60
61 /**
62 * 挂载到指定 document(H5 用 window.document;App renderjs 用当前页面的 document)
63 * - 遍历键盘事件的捕获阶段,接管方向/确认键
64 */
65 mount(documentObj) {
66 this.doc = documentObj
67 if (!this.doc || !this.doc.addEventListener) return this
68 // 清理旧监听,避免重复挂载
69 if (this.handler) this.doc.removeEventListener('keydown', this.handler, true)
70 this.handler = this.onKeyDown.bind(this)
71 this.doc.addEventListener('keydown', this.handler, true)
72 this.ensureRing()
73 return this
74 }
75
76 unmount() {
77 if (this.doc && this.handler) {
78 this.doc.removeEventListener('keydown', this.handler, true)
79 }
80 if (this.ring && this.ring.parentNode) {
81 this.ring.parentNode.removeChild(this.ring)
82 }
83 this.ring = null
84 this.currentEl = null
85 this.nodes = []
86 this._scopeEl = null
87 this._prevFocusEl = null
88 this._highlighted = null
89 }
90
91 onKeyDown(e) {
92 const t = e.target
93 const k = e.keyCode || e.which
94 // 输入框/文本域聚焦时不拦截,交给系统输入法(遥控器字母/方向用于输入)。
95 // 但必须保留一个退出键,否则遥控焦点会永远困在输入框内,无法回到页面。
96 if (t && t.tagName && INPUT_TAG.test(t.tagName)) {
97 if (k === 27 || k === 461) {
98 e.preventDefault()
99 if (t.blur) t.blur()
100 if (this.currentEl) this.setFocus(this.currentEl)
101 }
102 return
103 }
104 for (const name in KEYMAP) {
105 if (KEYMAP[name].includes(k)) {
106 if (name === 'enter') {
107 e.preventDefault()
108 this.activate()
109 } else if (name === 'back') {
110 if (this.onBack) this.onBack()
111 } else if (name === 'menu') {
112 if (this.onMenu) this.onMenu()
113 } else {
114 // 方向键:阻止默认焦点/滚动,完全交给引擎
115 e.preventDefault()
116 this.move(name)
117 }
118 return
119 }
120 }
121 }
122
123 /** 当前导航作用域:取最后一个可见的 [data-nav-scope](弹层面板) */
124 getScopeEl() {
125 const doc = this.doc || (typeof document !== 'undefined' ? document : null)
126 if (!doc || !doc.querySelectorAll) return null
127 const list = doc.querySelectorAll('[data-nav-scope]')
128 for (let i = list.length - 1; i >= 0; i--) {
129 const el = list[i]
130 if (!el || !el.getBoundingClientRect) continue
131 const r = el.getBoundingClientRect()
132 if (r.width || r.height) return el
133 }
134 return null
135 }
136
137 /** 节点是否仍在文档中(弹层关闭后原节点可能已被销毁) */
138 isAlive(el) {
139 const doc = this.doc || (typeof document !== 'undefined' ? document : null)
140 if (!el) return false
141 // 用 document.contains 判断:被整体移除的子树里节点仍持有 parentNode,
142 // 只看 parentNode 会把已销毁的节点误判为存活
143 if (doc && doc.contains) return doc.contains(el)
144 return !!(el.parentNode && el.parentNode.nodeType === 1)
145 }
146
147 /**
148 * 同步弹层作用域:弹层打开时把焦点带进弹层,关闭后还原到进入前的元素。
149 * @param {boolean} isActivate 是否由确认键触发(关闭弹层时需消费确认键,
150 * 否则会误触「刚才打开弹层的那个按钮」,导致弹层反复开关)
151 * @returns {boolean} 是否消费本次按键
152 */
153 syncScope(isActivate) {
154 const el = this.getScopeEl()
155 if (el === this._scopeEl) return false
156 const prev = this.currentEl
157 this._scopeEl = el
158 this.collect()
159 // 弹层打开:焦点进入弹层首项,并消费本次按键
160 if (el) {
161 if (this.nodes.length) {
162 this._prevFocusEl = prev
163 this.setFocus(this.nodes[0].el)
164 return true
165 }
166 // 作用域内没有可聚焦项(如仅剩遮罩):放弃限制,避免所有按键都失效
167 this._scopeEl = null
168 this.collect()
169 return false
170 }
171 // 弹层关闭:还原进入前的焦点;方向键不消费(可立即继续导航)
172 if (this.isAlive(this._prevFocusEl)) {
173 this.setFocus(this._prevFocusEl)
174 } else {
175 this.currentEl = null
176 if (this.ring) this.ring.style.display = 'none'
177 }
178 return !!isActivate
179 }
180
181 /** 采集当前可见的可聚焦节点(每次导航前重建,坐标始终最新) */
182 collect(root) {
183 const doc = this.doc || (typeof document !== 'undefined' ? document : null)
184 if (!doc) return this.nodes
185 this.doc = doc
186 // 弹层优先:存在可见的 data-nav-scope 时只在其内部导航,
187 // 否则焦点会跑到被遮罩覆盖的页面元素上
188 const scope = this.getScopeEl()
189 this._scopeEl = scope
190 const base = scope || root || doc.body || doc.documentElement
191 if (!base || !base.querySelectorAll) return []
192 const viewportH = doc.documentElement ? doc.documentElement.clientHeight : 0
193 const viewportW = doc.documentElement ? doc.documentElement.clientWidth : 0
194 // 视口判定向外放宽一个边距:被裁在屏幕外的「下一行/下一项」必须能被聚焦,
195 // 聚焦后由 scrollIntoView 滚入可视区。否则遥控只能选中当前屏内的项,长列表无法滚动。
196 const margin = viewportH ? Math.max(120, viewportH * 0.5) : 0
197 const marginX = viewportW ? Math.max(120, viewportW * 0.5) : 0
198 const arr = Array.prototype.slice.call(base.querySelectorAll(NAV_SELECTOR))
199 this.nodes = arr
200 .map((el) => {
201 if (!el || !el.getBoundingClientRect) return null
202 const r = el.getBoundingClientRect()
203 // 只保留非 0 尺寸、且落在(放宽后)视口范围内的项;
204 // 横向同样过滤,避免采集到页面栈中已横向移出/隐藏的节点
205 if (!r.width && !r.height) return null
206 if (viewportH && (r.bottom < -margin || r.top > viewportH + margin)) return null
207 if (viewportW && (r.right < -marginX || r.left > viewportW + marginX)) return null
208 return { el, x: r.left + r.width / 2, y: r.top + r.height / 2, w: r.width, h: r.height }
209 })
210 .filter(Boolean)
211 return this.nodes
212 }
213
214 /** 计算指定方向上的最近目标(经典投影法 + 侧向偏移惩罚) */
215 nearest(from, dir) {
216 if (!this.nodes.length) return null
217 let best = null
218 let bestScore = Infinity
219 for (const n of this.nodes) {
220 if (n.el === from.el) continue
221 const dx = n.x - from.x
222 const dy = n.y - from.y
223 // 必须落在该方向象限;防止 180° 反向跳转
224 if (dir === 'left' && dx >= 0) continue
225 if (dir === 'right' && dx <= 0) continue
226 if (dir === 'up' && dy >= 0) continue
227 if (dir === 'down' && dy <= 0) continue
228 const isHorizontal = dir === 'left' || dir === 'right'
229 const main = isHorizontal ? Math.abs(dx) : Math.abs(dy)
230 const side = isHorizontal ? Math.abs(dy) : Math.abs(dx)
231 // 主轴优先,侧向偏移放大惩罚,避免跳到斜对角
232 const score = main + side * 2.2
233 if (score < bestScore) {
234 bestScore = score
235 best = n
236 }
237 }
238 return best
239 }
240
241 /** 移动焦点 */
242 move(dir) {
243 if (!this.enabled) return
244 // 弹层刚打开:本次按键只用于把焦点带进弹层,不继续导航
245 if (this.syncScope(false)) return
246 this.collect()
247 // 无当前焦点或当前项已失效时,从第一个节点出发(列表页可顺势滚动)
248 const from = this.nodes.find((n) => n.el === this.currentEl) || this.nodes[0]
249 if (!from) return
250 const target = this.nearest(from, dir) || from
251 this.setFocus(target.el)
252 }
253
254 /** 激活当前项(触发点击) */
255 activate() {
256 if (!this.enabled) return
257 if (this.syncScope(true)) return
258 // 当前焦点可能已随弹层/列表刷新被销毁,必须校验,否则确认键会永久失效
259 if (!this.currentEl || !this.isAlive(this.currentEl)) {
260 this.collect()
261 if (!this.nodes.length) return
262 this.setFocus(this.nodes[0].el)
263 }
264 const el = this.currentEl
265 el.click()
266 // 输入类元素补一次 focus,确保遥控端能唤起输入法
267 if (el.tagName && INPUT_TAG.test(el.tagName) && el.focus) el.focus()
268 }
269
270 /** 设焦 */
271 setFocus(el) {
272 if (!el) return
273 this.currentEl = el
274 this.ensureRing()
275 // 只需改动「上一个」与「当前」两项的高亮,无需遍历全部节点
276 if (this._highlighted && this._highlighted !== el && this._highlighted.classList) {
277 this._highlighted.classList.remove('tv-focused')
278 }
279 if (el.classList) el.classList.add('tv-focused')
280 this._highlighted = el
281 const doc = this.doc || document
282 if (doc) {
283 // 滚动到可见(方向导航时块内滚动)
284 try {
285 el.scrollIntoView && el.scrollIntoView({ block: 'nearest' })
286 } catch (e) {}
287 // 延迟到滚动后读取新坐标(保证焦点框定位准确)
288 const apply = () => {
289 if (!this.ring || this.currentEl !== el) return
290 const r = el.getBoundingClientRect()
291 if (!r.width && !r.height) return
292 this.ring.style.display = 'block'
293 this.ring.style.left = r.left + 'px'
294 this.ring.style.top = r.top + 'px'
295 this.ring.style.width = r.width + 'px'
296 this.ring.style.height = r.height + 'px'
297 }
298 apply()
299 setTimeout(apply, 30)
300 }
301 if (this.onMove) this.onMove(el)
302 }
303
304 /** 聚焦第一个可聚焦项 */
305 focusFirst() {
306 this.collect()
307 if (this.nodes.length) this.setFocus(this.nodes[0].el)
308 }
309
310 /** 外部调用:聚焦指定 data-navid(如页面进入时自动定位到首屏按钮) */
311 focusByIndex(i) {
312 this.collect()
313 if (this.nodes[i]) this.setFocus(this.nodes[i].el)
314 }
315
316 ensureRing() {
317 if (this.ring) return
318 const doc = this.doc || (typeof document !== 'undefined' ? document : null)
319 if (!doc || !doc.createElement) return
320 this.ring = doc.createElement('div')
321 this.ring.className = 'tv-focus-ring'
322 this.ring.style.position = 'fixed'
323 this.ring.style.zIndex = '99999'
324 this.ring.style.pointerEvents = 'none'
325 this.ring.style.display = 'none'
326 this.ring.style.transition = 'left .18s ease, top .18s ease, width .18s ease, height .18s ease'
327 this.ring.style.boxSizing = 'border-box'
328 // 挂到页面根容器内以继承主题 CSS 变量(fixed 定位不受父级影响)
329 const host = doc.querySelector('.page') || doc.querySelector('.play-detail') || doc.body
330 if (host) host.appendChild(this.ring)
331 }
332}
333
334/**
335 * 创建并挂载引擎,返回引擎实例。
336 * @param {object} opts { doc, name, onBack, onMenu }
337 */
338export function createFocusEngine(opts = {}) {
339 const engine = new TVFocusEngine(opts)
340 const dd = opts.doc || (typeof document !== 'undefined' ? document : null)
341 if (dd) engine.mount(dd)
342 return engine
343}