JS 手写 D-pad 空间导航:电视端焦点引擎实战
电视盒子没有鼠标,只有上下左右加确认。浏览器不像 Android 框架那样自带焦点推导,按右键该跳到哪张卡片,全得自己算。我给自家电视做了套零构建 Web 应用(直播、启动器、公版影视三个模块),把遥控器那套 D-pad 空间导航手写了一遍,这篇摊开讲推导和实测数据。
一、电视端和桌面端,根本不是一回事
很多"电视端适配"做不好,是因为一开始就拿桌面的心智模型去套。真正的差异在这一层:
| 维度 | 桌面 Web | 电视端 Web |
|---|---|---|
| 指针设备 | 鼠标精确点击 | 无,只有五向键 |
| 焦点来源 | :focus / :hover 浏览器托管 |
自己维护一个 focusIndex |
| 方向语义 | Tab 顺序(DOM 顺序) | 屏幕几何位置(视觉最近) |
| 滚动方式 | 滚轮 / 拖拽 | scrollIntoView({block:'nearest'}) |
| 观看距离 | 0.5 米 | 约 3 米,字号要放大 1.5 倍以上 |
| GPU 能力 | 独显 | 盒子普遍孱弱,backdrop-filter 是奢侈品 |
最要命的是第二行:电视上没有 hover。用户的唯一反馈就是"当前哪一项被点亮了",所以焦点状态必须是显式维护的,不能指望浏览器。
二、三个模块,一套内核
项目一共四个目录,前三个是真正跑在电视上的 Web 应用,第四个是打包工具:
| 目录 | 模块名 | 职责 | 行数 |
|---|---|---|---|
iptv-player/ |
LiveGlass | 播放你自己合法的 IPTV 源(.m3u),HLS 解码 | JS 208 行 |
streaming-launcher/ |
HomeGlass | 毛玻璃桌面,卡片直达已订阅平台 | JS 73 行 |
public-films/ |
FreeGlass | 检索 Internet Archive 公版影视并内嵌播放 | JS 116 行 |
apk-build/ |
pack.py | 把上面任一网页塞进 Android WebView 壳 | Python 44 行 |
三个应用没有任何构建步骤、没有任何 npm 依赖 (直播模块只从 CDN 引了 hls.js),双击 index.html 或者起个 python -m http.server 就能跑。这对电视盒子很重要------你不会想在电视上装 Node 环境。
三、技术选型:为什么没用现成的空间导航库
2026 年这块其实已经有成熟方案了,Norigin 的 spatial-navigation 月下载量九万+,今年还出了 React Native 版;@tv-spatial-navigation 系列也补齐了 Vue / Angular / React 适配器。选型时我做过对比:
| 需求 | 候选 | 最终选择 | 理由 |
|---|---|---|---|
| 焦点导航 | Norigin / tv-spatial-navigation / 自写 | 自写 | 布局是规则网格,14 行 switch 就够;引库反而要维护 region 注册与生命周期 |
| 直播解码 | 原生 video / hls.js | hls.js + 原生兜底 | Android 盒子 WebView 大多不支持原生 HLS,Safari 支持 |
| 影视检索 | 自建后端 / 直连 IA 接口 | 直连 | 保持零后端,检索接口本身公开免费 |
| 打包 APK | Capacitor / 自写 WebView 壳 | 自写壳 | 只有一个 Activity,省掉几十 MB 运行时,还能定制 JSBridge |
| UI 风格 | 纯色扁平 / 毛玻璃 | 毛玻璃 | 大屏深色背景 + 模糊层能拉开层次,代价是要控住模糊半径 |
这里必须说句实话:如果你的布局不是规则网格(比如 Netflix 那种横向 carousel 嵌套纵向列表),别自写,直接上库。自写的前提是我能接受"行内左右、行间上下"这个简化模型。
四、核心原理一:列数不写死,从计算样式反推
网格用的是 CSS 自适应的经典写法:
css
/* 卡片最小 220px,容器能塞几列就塞几列,剩余空间平分 */
.grid{
display:grid;
grid-template-columns:repeat(auto-fill,minmax(220px,1fr));
gap:22px;
}
这样一来 JS 根本不知道当前是几列------不同电视分辨率下差别很大。解决办法是反过来问 CSS:
js
// 每次渲染完 / 窗口变化后,读一次计算样式,数有几个轨道就是几列
function computeColumns(){
const cs = getComputedStyle(grid).gridTemplateColumns.split(' ').filter(Boolean);
columns = Math.max(1, cs.length);
}
window.addEventListener('resize', computeColumns); // 转屏/换分辨率要重算
getComputedStyle 拿到的是浏览器已经算好的像素轨道串 (如 "220px 220px 220px 220px 220px"),直接数个数即可,比自己用 offsetWidth 去除以卡片宽度可靠得多。实测各分辨率下的列数:
| 分辨率 | 内容宽(px) | 启动器列数 minmax(220px,1fr) gap22 |
影视馆列数 minmax(200px,1fr) gap20 |
|---|---|---|---|
| 3840 (4K) | 3760 | 15 | 17 |
| 2560 | 2480 | 10 | --- |
| 1920 (1080p) | 1840 | 7 | 8 |
| 1600 | 1520 | 6 | 7 |
| 1366 | 1286 | 5 | 5 |
| 1024 | 944 | 3 | 4 |
同一个页面,1080p 下是 7 列、4K 下是 15 列。如果硬编码列数,换台电视焦点就全乱了------这正是"必须从计算样式反推"的原因。
五、核心原理二:焦点移动用钳制,不是取模
有了列数,方向键就退化成一维索引运算:
js
document.addEventListener('keydown', (e) => {
const last = APPS.length - 1;
switch (e.key) {
// 左右:行内移动,越界就粘在边界(Math.min / Math.max 钳制)
case 'ArrowRight': focusIndex = Math.min(focusIndex + 1, last); break;
case 'ArrowLeft': focusIndex = Math.max(focusIndex - 1, 0); break;
// 上下:整行跳动,同样是钳制而不是绕回
case 'ArrowDown': focusIndex = Math.min(focusIndex + columns, last); break;
case 'ArrowUp': focusIndex = Math.max(focusIndex - columns, 0); break;
case 'Enter': open(focusIndex); break;
}
updateFocus();
e.preventDefault(); // 关键:不 preventDefault 浏览器会顺带滚页面
});
为什么不用取模((i + 1) % n 循环)? 因为遥控器长按会连发。取模的话长按右键会一圈圈绕,用户完全失去位置感;钳制则是"顶到边界就停住",配合 scrollIntoView 反而更符合预期。
但这个模型不完美。我把源码里的公式抽出来在 Node 里跑了穷举审计------枚举每个索引 × 四个方向的所有转移,然后分类:
| 场景 | 转移总数 | 正常 | 原地不动 | 被钳制粘住 | 左右键换行 | 上下键漂移 |
|---|---|---|---|---|---|---|
| 启动器 14 项 / 5 列 | 56 | 40 (71.4%) | 4 (7.1%) | 7 (12.5%) | 4 (7.1%) | 1 (1.8%) |
| 启动器 14 项 / 15 列(4K) | 56 | 26 (46.4%) | 4 (7.1%) | 26 (46.4%) | 0 | 0 |
| 影视馆 36 项 / 8 列 | 144 | 126 (87.5%) | 3 (2.1%) | 3 (2.1%) | 8 (5.6%) | 4 (2.8%) |
| 影视馆 36 项 / 5 列 | 144 | 123 (85.4%) | 3 (2.1%) | 0 | 14 (9.7%) | 4 (2.8%) |
两个值得注意的结论:
- 4K 下项数少于列数时,上下键彻底失效 。14 个卡片在 15 列里排成一行,
min(i+15, 13)恒等于 13,按上下键只会跳到最后一个。此时正确行为应该是"没有下一行就不动",而不是"跳到末项"。 - 末行下移会横向漂移。36 项 8 列时,第 28~31 项向下按会全部落到第 35 项(末项),视觉上焦点横着窜了 4 格。
修法很简单,判断目标索引是否还在同一列区间内:
js
// 改进版:目标行不存在就保持不动,而不是被 min 拉到末项
const target = focusIndex + columns;
if (target <= last) { focusIndex = target; updateFocus(); }
六、核心原理三:三层焦点的切换
电视应用最容易做崩的地方是弹窗和输入框。焦点不能只有一个全局索引,得有层级:
js
let modalOpen = false; // 是否处于弹窗层
let modalFocus = 0; // 弹窗内部的独立索引
const modalControls = [els.m3uUrl, els.m3uFile, els.useDemo, els.loadM3u];
document.addEventListener('keydown', (e) => {
// 第一层:弹窗打开时,方向键只服务弹窗,且 return 掉,不穿透到列表
if (modalOpen) {
switch (e.key) {
// 注意这里用的是取模------弹窗只有 4 个控件,循环反而更顺手
case 'ArrowDown': modalFocus = (modalFocus + 1) % modalControls.length; break;
case 'ArrowUp': modalFocus = (modalFocus - 1 + modalControls.length) % modalControls.length; break;
case 'Escape': closeModal(); break;
}
focusModal();
return;
}
// 第二层:列表......(见上一节)
});
影视馆模块还多做了一层------首行向上把焦点交还给搜索框:
js
case 'ArrowUp':
if (focusIndex < columns) {
els.search.focus(); // 回到真正的 DOM 焦点,让输入法能弹出来
inGrid = false;
} else {
focusIndex = Math.max(focusIndex - columns, 0);
updateFocus();
}
break;
这里必须调真正的 .focus() 而不是加 class:搜索框是 <input>,不加 DOM 焦点的话遥控器没法调出输入法打字。列表项是自己画的高亮,弹窗和输入框是 DOM 焦点,两套机制并存是这套代码的现实妥协。
七、核心原理四:M3U 解析与 HLS 容错
IPTV 源就是个纯文本,重点在 #EXTINF 和紧随其后的 URL:
js
function parseM3U(text) {
const lines = text.split(/\r?\n/); // 兼容 Windows 的 CRLF
const out = [];
let name = '';
for (const raw of lines) {
const line = raw.trim();
if (line.startsWith('#EXTINF')) {
const m = line.match(/,(.+)$/); // 取第一个逗号之后的所有内容当台名
name = m ? m[1].trim() : '频道';
if (!name) name = '频道';
} else if (line && !line.startsWith('#')) {
// 没配对到台名的裸 URL 也要保留,用序号兜底
out.push({ name: name || ('频道 ' + (out.length + 1)), url: line });
name = '';
}
}
return out;
}
我拿一份刻意构造的样本做了对照测试(7 条频道,含属性里带逗号的情况):
| # | 源码写法(首个逗号后) | 对照写法(最后逗号后) | 一致 |
|---|---|---|---|
| 0 | CCTV-1 综合 | CCTV-1 综合 | 是 |
| 1 | 高清" group-title="卫视",湖南卫视 |
湖南卫视 | 否 |
| 2 | 频道 | 频道 | 是 |
| 3 | Discovery Channel | Discovery Channel | 是 |
| 4 | B,C",Channel ABC |
Channel ABC | 否 |
| 5 | 频道 6 | 频道 6 | 是 |
| 6 | 北京卫视 | 北京卫视 | 是 |
7 条里 2 条被属性中的逗号污染,错误率 28.6%。性能倒不是问题------2000 个频道的列表解析只花 2.22 ms。
播放侧的容错是双通道:
js
if (hls) { hls.destroy(); hls = null; } // 切台务必销毁旧实例,否则会并行拉流
const isHls = ch.url.includes('.m3u8');
if (isHls && window.Hls && Hls.isSupported()) {
hls = new Hls({ enableWorker: true }); // 解析放 worker,别占主线程
hls.loadSource(ch.url);
hls.attachMedia(v);
hls.on(Hls.Events.ERROR, (e, data) => {
if (data.fatal) els.meta.textContent = '播放出错:' + (data.details || '未知错误');
});
} else {
v.src = ch.url; // 原生支持(Safari)或普通 mp4,直接喂给 video
v.play().catch(() => {}); // 自动播放被拦时静默失败,不要抛未捕获异常
}
八、核心原理五:WebView 外壳与 JSBridge
网页要在电视上"像个 App",得有个壳。壳只有一个 Activity,41 行 Java:
java
public class MainActivity extends Activity {
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_FULLSCREEN);
webView = new WebView(this);
WebSettings ws = webView.getSettings();
ws.setJavaScriptEnabled(true);
ws.setDomStorageEnabled(true); // localStorage 要用
ws.setMediaPlaybackRequiresUserGesture(false); // 电视没有手势,必须放开
ws.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
ws.setBuiltInZoomControls(false); // 遥控器不能捏合缩放
webView.addJavascriptInterface(new JSBridge(), "AndroidBridge");
webView.loadUrl("file:///android_asset/index.html");
}
// 网页调 AndroidBridge.openApp(pkg, url):装了就拉起 App,没装就开官网
public class JSBridge {
@JavascriptInterface
public void openApp(String pkg, String url) {
Intent i = getPackageManager().getLaunchIntentForPackage(pkg);
if (i != null) { i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); startActivity(i); }
else { startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); }
}
}
}
打包脚本 pack.py 只做三件事:把网页目录整个拷进 assets/、正则改 applicationId、正则改 app_name。44 行 Python,改完用 Android Studio 签名出包即可。
九、踩坑记录
坑 1:设置按钮纯遥控器打不开。 直播模块里按右键只做了 settingsBtn.classList.add('focus'),而 Enter 分支写死 play(focusIndex)。结果就是高亮跑到了齿轮上,按确认却在播频道。修法是把"设置"也纳入焦点索引,或者给它加个 topbarFocus 标志位,让 Enter 分支判断当前焦点层。
坑 2:空网格让列数退化成 1。 getComputedStyle 在 grid 没有任何子元素时返回字符串 "none",split(' ').filter(Boolean) 得到 ['none'],长度 1,于是 columns = 1。首屏数据还没回来时按方向键行为会异常。默认值应该写 5 而不是靠这次计算,或者判断 === 'none' 时跳过更新。
坑 3:弹窗循环、列表不循环,行为不一致。 弹窗里用取模绕回,列表里用钳制。同一个遥控器两套规则,用户会懵。我的建议是统一用钳制,弹窗只有 4 项也足够短。
坑 4:台名里的逗号会让解析错位。 见第七节对照表,line.match(/,(.+)$/) 取的是第一个逗号,而 #EXTINF 的属性值里经常有逗号。改成 lastIndexOf(',') 更稳。
坑 5:切台不销毁 HLS 实例会叠加带宽。 每次 new Hls() 都在后台拉流,切 10 个台就是 10 条流并行,盒子直接卡死。play() 开头那句 hls.destroy() 不能省。
坑 6:file:// 下 fetch 会被跨域拦。 影视馆直接双击打开时,检索接口的 fetch 会失败。代码里已经把错误提示写清楚了,但更彻底的做法是走本地服务器或打包成 APK(asset 协议不受同源限制)。另外 backdrop-filter: blur(34px) 在老盒子上掉帧明显,实际部署建议降到 12~16px。
十、实测数据汇总
| 项目 | 实测值 |
|---|---|
| 启动器 JS 体积 | 73 行 / 5.0 KB,零依赖 |
| 直播播放器 JS | 208 行 / 7.9 KB(hls.js 走 CDN) |
| 打包脚本 | Python 44 行,改包名 + 拷 assets |
| 1080p 下列数 | 启动器 7 列 / 影视馆 8 列 |
| 4K 下列数 | 启动器 15 列 / 影视馆 17 列 |
| 焦点转移正确率(36 项 8 列) | 87.5% |
| 焦点转移正确率(4K 单行) | 46.4% |
| 2000 频道 M3U 解析耗时 | 2.22 ms |
| M3U 台名解析错误率(含逗号属性样本) | 28.6%(7 条中 2 条) |
十一、小结与后续
这套东西的价值不在"毛玻璃好看",而在于把遥控器交互的最小可用模型跑通了,四条经验可以直接搬走:
- 索引即焦点:别指望
:hover,自己维护一个focusIndex+ 高亮 class - 列数从 CSS 反推:
getComputedStyle数轨道,别硬编码 - 越界用钳制而非取模:长按连发时循环会让人失去位置感
- 层级间显式交还焦点:列表、弹窗、输入框各有一套索引下一步打算做两件事:一是把上下键的越界改成"不动",二是给焦点加记忆------从播放页返回时焦点应该落回刚才那个频道,而不是回到列表头。
完整工程已整理好(三个模块 + 一键打包脚本 + 遥控器键位说明),需要的同学评论区扣「源码」,我看到会一一回复;也欢迎关注我,后续会把大屏交互这个系列继续更下去。