Skip to content

Commit 5eb5a61

Browse files
author
linyuan.yang
committed
支持 android tv
1 parent 6e25e89 commit 5eb5a61

8 files changed

Lines changed: 498 additions & 6 deletions

File tree

packages/app/src/App.vue

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
ToolbarMenu,
1212
settings,
1313
sha256Hex,
14+
isTv,
15+
useRemoteNav,
1416
type SaveState,
1517
} from '@nes-emulator/player'
1618
import { isTauri, pickRomFile, storage } from './emulator/platform'
@@ -33,6 +35,23 @@ const inputLocked = computed(
3335
() => storeOpen.value || settingsOpen.value || savesOpen.value || helpOpen.value,
3436
)
3537
38+
// Android TV 遥控器:工具栏焦点导航。无模态、且(未载入或已暂停)时由工具栏接管方向键;
39+
// 游戏运行中则交还 NesScreen(方向键控制角色)。开机未载入时自动聚焦,解决"无入口"问题。
40+
const toolbarRef = ref<HTMLElement | null>(null)
41+
const toolbarNavActive = computed(
42+
() => isTv && !inputLocked.value && (!romName.value || paused.value),
43+
)
44+
useRemoteNav({
45+
container: toolbarRef,
46+
active: toolbarNavActive,
47+
autoFocus: isTv,
48+
priority: 0,
49+
})
50+
// 焦点环作用到全局(含 Teleport 到 body 的下拉菜单):给 <html> 标记 tv-nav。
51+
if (isTv && typeof document !== 'undefined') {
52+
document.documentElement.classList.add('tv-nav')
53+
}
54+
3655
// 快捷键修饰键:桌面(Tauri)用 Ctrl,浏览器用 Shift(避开 Ctrl+L/N 等浏览器冲突)。
3756
const modKey: 'ctrl' | 'shift' = isTauri ? 'ctrl' : 'shift'
3857
const modLabel = isTauri ? 'Ctrl' : 'Shift'
@@ -133,8 +152,8 @@ function onSystemAction(action: string) {
133152
</script>
134153

135154
<template>
136-
<div class="app">
137-
<header class="toolbar">
155+
<div class="app" :class="{ 'tv-nav': isTv }">
156+
<header ref="toolbarRef" class="toolbar">
138157
<h1 class="title">NES/FC 模拟器</h1>
139158
<div class="spacer" />
140159
<button class="btn" :disabled="loading" @click="openRom">
@@ -180,7 +199,7 @@ function onSystemAction(action: string) {
180199
<main class="stage">
181200
<NesScreen
182201
ref="screen"
183-
:input-locked="inputLocked"
202+
:input-locked="inputLocked || paused"
184203
:modifier="modKey"
185204
:rom-key="romKey"
186205
:rom-name="romName"
@@ -192,6 +211,7 @@ function onSystemAction(action: string) {
192211
<footer class="footer">
193212
<span class="keys">键盘 {{ keyHint }}</span>
194213
<button class="link-btn" @click="helpOpen = true">查看所有快捷键</button>
214+
<span v-if="isTv" class="tv-tip">遥控器可操作菜单与基础游玩,完整体验建议连接蓝牙手柄</span>
195215
<span class="meta">{{ romName ?? '未载入' }} · {{ isTauri ? 'Tauri App' : '浏览器预览' }}</span>
196216
</footer>
197217
</div>
@@ -216,6 +236,20 @@ body {
216236
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
217237
overflow: hidden;
218238
}
239+
/* Android TV 遥控器焦点环:仅 TV(<html>.tv-nav)启用,桌面键鼠不受影响。
240+
补 :focus 兜底,因部分 Android WebView 对 button 不触发 :focus-visible。 */
241+
.tv-nav :focus-visible,
242+
.tv-nav button:focus,
243+
.tv-nav a:focus,
244+
.tv-nav input:focus,
245+
.tv-nav select:focus,
246+
.tv-nav textarea:focus,
247+
.tv-nav [tabindex]:focus,
248+
.tv-nav [data-nav]:focus {
249+
outline: 3px solid #4ea1ff;
250+
outline-offset: 2px;
251+
border-radius: 6px;
252+
}
219253
</style>
220254

221255
<style scoped>
@@ -312,6 +346,9 @@ body {
312346
color: #777;
313347
margin-left: auto;
314348
}
349+
.footer .tv-tip {
350+
color: #6ab0ff;
351+
}
315352
.link-btn {
316353
border: none;
317354
background: transparent;

packages/player/src/components/NesScreen.vue

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,21 @@ import {
2121
TURBO_TARGET,
2222
type Aspect,
2323
} from '../emulator/settings'
24+
import { isTv } from '../emulator/platform'
25+
26+
// Android TV 遥控器游戏内映射(仅 TV 启用,不影响桌面键盘):
27+
// 方向键→玩家1 十字键,确定键(Enter)→A;BACK 由 onKeyDown 单独处理为"暂停/打开菜单"。
28+
// 遥控器物理键有限(无 B/Start/Select),完整游玩仍建议连蓝牙手柄。
29+
const TV_REMOTE_MAP: Record<string, PadButton> = isTv
30+
? {
31+
ArrowUp: PadButton.Up,
32+
ArrowDown: PadButton.Down,
33+
ArrowLeft: PadButton.Left,
34+
ArrowRight: PadButton.Right,
35+
Enter: PadButton.A,
36+
NumpadEnter: PadButton.A,
37+
}
38+
: {}
2439
2540
// inputLocked:模态(游戏库/设置)打开时为真,此时不把输入喂给游戏。
2641
// romKey/romName:当前载入卡带的稳定标识与显示名,用于把存档绑定到对应游戏。
@@ -148,6 +163,21 @@ function isTypingTarget(e: KeyboardEvent): boolean {
148163
function onKeyDown(e: KeyboardEvent) {
149164
if (isTypingTarget(e)) return // 正在输入框打字,不抢按键
150165
if (props.inputLocked) return // 模态接管,游戏不收输入
166+
// Android TV 遥控器(仅 isTv):BACK→暂停/菜单,方向→玩家1 十字键,确定(Enter)→A。
167+
// 必须在 codeToAction 之前覆盖(否则方向键命中玩家2、Enter 命中玩家1 Start)。
168+
if (isTv) {
169+
if (e.code === 'Backspace' || e.key === 'GoBack' || e.key === 'BrowserBack') {
170+
e.preventDefault()
171+
if (!e.repeat) emit('systemAction', 'toggle-pause')
172+
return
173+
}
174+
const tvBtn = TV_REMOTE_MAP[e.code]
175+
if (tvBtn !== undefined) {
176+
e.preventDefault()
177+
pressPad(0, tvBtn)
178+
return
179+
}
180+
}
151181
// 应用级快捷键:修饰键由 modifier 决定(桌面 Ctrl / 浏览器 Shift,避开浏览器占用键)。
152182
// S=存档,L=读档,N=新建存档,A=存档列表,G=游戏库,P=暂停,F=全屏;Esc 也可暂停。
153183
const modActive = props.modifier === 'ctrl' ? e.ctrlKey : e.shiftKey
@@ -196,6 +226,14 @@ function onKeyDown(e: KeyboardEvent) {
196226
function onKeyUp(e: KeyboardEvent) {
197227
if (isTypingTarget(e)) return
198228
if (props.inputLocked) return
229+
if (isTv) {
230+
const tvBtn = TV_REMOTE_MAP[e.code]
231+
if (tvBtn !== undefined) {
232+
e.preventDefault()
233+
releasePad(0, tvBtn)
234+
return
235+
}
236+
}
199237
const a = codeToAction.value[e.code]
200238
if (!a) return
201239
e.preventDefault()

packages/player/src/components/SaveStatePanel.vue

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
saveKindOf,
1111
type SaveStateRecord,
1212
} from '../store/saveState'
13+
import { isTv } from '../emulator/platform'
14+
import { useRemoteNav } from '../composables/useRemoteNav'
1315
1416
// 读档时交回父组件应用到引擎的载荷:带 bytes 表示需先载入对应游戏,
1517
// 不带 bytes 表示当前已是该游戏、直接套用状态即可。
@@ -39,6 +41,18 @@ const progress = ref<DownloadProgress | null>(null)
3941
const editingKey = ref<string | null>(null)
4042
const editingText = ref('')
4143
44+
// Android TV 遥控器:面板内焦点导航(搜索框/筛选/卡片读档·删除·重命名按钮/关闭)。
45+
const panelRef = ref<HTMLElement | null>(null)
46+
useRemoteNav({
47+
container: panelRef,
48+
active: computed(() => isTv && open.value),
49+
onBack: () => {
50+
if (busyKey.value === null) open.value = false
51+
},
52+
autoFocus: true,
53+
priority: 10,
54+
})
55+
4256
watch(
4357
open,
4458
(value) => {
@@ -189,7 +203,7 @@ function formatError(err: unknown): string {
189203

190204
<template>
191205
<div v-if="open" class="store-backdrop" @click.self="busyKey === null && (open = false)">
192-
<section class="store-panel" role="dialog" aria-modal="true" aria-labelledby="save-panel-title">
206+
<section ref="panelRef" class="store-panel" role="dialog" aria-modal="true" aria-labelledby="save-panel-title">
193207
<header class="store-header">
194208
<div>
195209
<h2 id="save-panel-title">存档列表</h2>

packages/player/src/components/SettingsModal.vue

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
type Aspect,
1818
type TouchPadMode,
1919
} from '../emulator/settings'
20+
import { isTv } from '../emulator/platform'
21+
import { useRemoteNav } from '../composables/useRemoteNav'
2022
2123
const open = defineModel<boolean>('open', { default: false })
2224
@@ -168,6 +170,19 @@ function close() {
168170
open.value = false
169171
}
170172
173+
// Android TV 遥控器:设置面板内焦点导航(tab/玩家切换/改键/各控件/关闭)。
174+
// 改键捕获态(capturing / capturingPad)时让位 —— 此时键盘/手柄全交给捕获逻辑。
175+
const panelRef = ref<HTMLElement | null>(null)
176+
useRemoteNav({
177+
container: panelRef,
178+
active: computed(
179+
() => isTv && open.value && capturing.value === null && capturingPad.value === null,
180+
),
181+
onBack: close,
182+
autoFocus: true,
183+
priority: 10,
184+
})
185+
171186
const volumePct = computed({
172187
get: () => Math.round(settings.audio.volume * 100),
173188
set: (v: number) => {
@@ -202,7 +217,7 @@ onBeforeUnmount(() => {
202217
@click.self="close"
203218
@keydown="onOverlayKey"
204219
>
205-
<div class="panel" role="dialog" aria-label="设置">
220+
<div ref="panelRef" class="panel" role="dialog" aria-label="设置">
206221
<header class="panel-head">
207222
<h2>设置</h2>
208223
<button class="x" @click="close">✕</button>

packages/player/src/components/ToolbarMenu.vue

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,24 @@
44
* 点击外部或按 Esc 自动收起;点击任一菜单项后也收起(slot 内容冒泡到容器触发 close)。
55
* 菜单项请用 class="menu-item"(可含 <kbd> 显示快捷键),样式由本组件经 :slotted 提供。
66
*/
7-
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
7+
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
8+
import { isTv } from '../emulator/platform'
9+
import { useRemoteNav } from '../composables/useRemoteNav'
810
911
defineProps<{ label: string }>()
1012
1113
const open = ref(false)
1214
const root = ref<HTMLElement | null>(null)
1315
const btn = ref<HTMLElement | null>(null)
16+
// 下拉(Teleport 到 body)的遥控器焦点导航:打开时自动聚焦首项,方向键切项,OK 选中(冒泡触发 close)。
17+
const dropdownRef = ref<HTMLElement | null>(null)
18+
useRemoteNav({
19+
container: dropdownRef,
20+
active: computed(() => isTv && open.value),
21+
onBack: close,
22+
autoFocus: true,
23+
priority: 20,
24+
})
1425
// 下拉用 Teleport 挂到 body,避免被工具栏的 overflow:auto 裁剪;打开时按触发按钮定位。
1526
const pos = ref({ top: 0, left: 0 })
1627
@@ -49,6 +60,7 @@ onBeforeUnmount(() => {
4960
<Teleport to="body">
5061
<div
5162
v-if="open"
63+
ref="dropdownRef"
5264
class="dropdown"
5365
:style="{ top: pos.top + 'px', left: pos.left + 'px' }"
5466
@click="close"

0 commit comments

Comments
 (0)