JS 手写 D-pad 空间导航:电视端焦点引擎实战

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%)

两个值得注意的结论:

  1. 4K 下项数少于列数时,上下键彻底失效 。14 个卡片在 15 列里排成一行,min(i+15, 13) 恒等于 13,按上下键只会跳到最后一个。此时正确行为应该是"没有下一行就不动",而不是"跳到末项"。
  2. 末行下移会横向漂移。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 数轨道,别硬编码
  • 越界用钳制而非取模:长按连发时循环会让人失去位置感
  • 层级间显式交还焦点:列表、弹窗、输入框各有一套索引下一步打算做两件事:一是把上下键的越界改成"不动",二是给焦点加记忆------从播放页返回时焦点应该落回刚才那个频道,而不是回到列表头。

完整工程已整理好(三个模块 + 一键打包脚本 + 遥控器键位说明),需要的同学评论区扣「源码」,我看到会一一回复;也欢迎关注我,后续会把大屏交互这个系列继续更下去。

相关推荐
2601_962218611 小时前
C++的几种编译器的实现
开发语言·c++
恋猫de小郭1 小时前
Android 原生的 Compose A2UI 也来了,你还抱着 XML 养老吗?
android·前端·flutter
庄园特聘拆椅狂魔1 小时前
从连连看到粒子消散——前端小游戏与视觉特效的技术选型
前端
程序媛_1 小时前
【JMeter】准备token文件
android·jmeter
多弗朗皮卡丘1 小时前
C++ stack和queue
开发语言·c++·deque·queue·stack·容器适配器
JudithHuang1 小时前
React 常用 Hooks
前端·react.js·前端框架
维克兜率天1 小时前
【维克】弹性策略:用“乖离率“捕捉超跌反弹
android·开发语言·python·深度学习·kotlin·量化
TOOLS指南1 小时前
Chatshare 域名乱象:如何辨别仿冒站点,避坑指南
开发语言·信息可视化
茉莉玫瑰花茶2 小时前
GO [ 函数 ]
开发语言·后端·golang