首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >从 0 到 1 打造离线可用的手机提词器:单文件 HTML + PWA 实战

从 0 到 1 打造离线可用的手机提词器:单文件 HTML + PWA 实战

原创
作者头像
不着急的老伙计
修改2026-08-22 13:28:36
修改2026-08-22 13:28:36
1300
举报
文章被收录于专栏:WorkBuddy实战WorkBuddy实战

从 0 到 1 打造离线可用的手机提词器:单文件 HTML + PWA 实战


导语

做口播、直播、讲课,经常需要一份能随时滚动的台词。本次实践的目标很明确:写一个不依赖任何 App 商店、不依赖网络、用手机浏览器即可运行的轻量提词器。

最终产物是一个 23KB 的单文件 HTML,支持:分镜时间轴、横竖屏自适应、多文案切换、调速/字号/配色/镜像/倒计时、草稿本地保存,以及可选的 PWA「添加到主屏幕」能力。

本文将完整复盘从选型到落地的过程,并附上三个真实踩坑,帮你在做类似移动端工具时少走弯路。


一、技术选型:为什么是「单文件 HTML」

方案

跨平台

离线

分发成本

上手门槛

原生 App(Android/iOS)

需双端

高(商店审核、签名)

跨端框架(Flutter/RN)

中(打包、上架)

中高

单文件 HTML

全平台(浏览器即运行时)

是(无外部依赖)

低(发个文件即可)

选择单文件 HTML 的核心原因:

  1. 分发极简:一个 .html 通过 IM 工具、U 盘、网盘发出去,手机浏览器打开即用,不需要应用商店。
  2. 零依赖离线:所有 CSS/JS 内联在一个文件里,不请求任何 CDN,断网照样跑。
  3. 改起来快:调一个函数、刷新页面就生效,没有编译链路。

唯一短板是「安装到桌面 / 离线缓存」需要 PWA(HTTPS 环境),但纯离线用浏览器打开已完全够用,PWA 只是锦上添花。


二、整体架构:编辑界面 + 播放界面

页面只有两个「屏幕」,用 display 切换:

  • 编辑界面(#editor:写台词、调参数、管理多条文案、查看分镜列表。
  • 播放界面(#player:全屏黑底,台词从屏幕底部匀速升入、向结尾滚动。

关键设计:滚动用 transform: translateY() 而非 scrollTop。因为 translateY 走 GPU 合成层,丝滑不卡,且能轻松叠加镜像(scaleX(-1))用于玻璃提词板。

代码语言:css
复制
#scroll { position: absolute; inset: 0; will-change: transform; padding: 0 4vw; text-align: center; }

三、核心实现逐个讲

1. 正向滚动(最容易写反的地方)

最初版本用 flex-end 让内容底贴屏幕底,结果一进播放就只显示稿子末尾,正向滚动完全反了。正确做法是:正文自然排列,开头从屏幕底部 92% 处升入,向结尾方向滚动

代码语言:js
复制
function measure() {
  var innerTop = scrollInner.getBoundingClientRect().top;
  var contentH = scrollInner.getBoundingClientRect().height;
  var vh = player.getBoundingClientRect().height;
  startOff = vh * 0.92;                       // 开头从屏幕底部升入
  total = Math.max(0, contentH + startOff - vh);  // 滚动总距离
  render();
}

function render() {
  scroll.style.transform =
    (state.mirror ? "scaleX(-1) " : "") +
    "translateY(" + (startOff - pos) + "px)";   // pos 从 0 增大,内容向上走
}

滚动循环用 requestAnimationFrame,按 speed(px/s)× 时间增量推进:

代码语言:js
复制
function loop(ts) {
  if (!playing) return;
  if (!last) last = ts;
  var dt = (ts - last) / 1000; last = ts;
  pos += state.speed * dt;
  if (pos >= total) { pos = total; playing = false; }
  render();
  raf = requestAnimationFrame(loop);
}

2. 分镜时间轴(空行即分镜)

用户用「空行」分段 = 一个分镜。代码按空行切分,自动生成编辑区分镜列表 + 播放时顶部时间轴节点:

代码语言:js
复制
function splitScenes(txt) {
  return txt.replace(/\r/g, "")
            .split(/\n\s*\n/)
            .map(b => b.trim())
            .filter(b => b.length);
}

每个分镜首句作为预览,按 ~3.5 字/秒估算时长;点列表项或时间轴节点即可 jumpToScene(i) 跳到对应分镜的开头位置。

3. 横竖屏自适应

监听 resizeorientationchange,旋转后重算字号并重新测量滚动距离。横屏时字号自动放大 12%,一行容纳更多字:

代码语言:js
复制
function effFont() {
  var f = state.font;
  if (window.innerWidth > window.innerHeight) f = Math.min(140, Math.round(f * 1.12));
  return f;
}
window.addEventListener("resize", onOrient);
window.addEventListener("orientationchange", onOrient);

播放时还提供 按钮主动调用 screen.orientation.lock('landscape') 锁定横屏,给不喜欢物理旋转的老板们。

4. 多文案库(localStorage 数组存储)

本场口播常有几套词来回切,所以把文案存成数组,每条含 id/title/text

代码语言:js
复制
var KEY_LIB = "teleprompter_lib_v2";
var library = [];                 // [{id, title, text}, ...]
// 切换
function loadPrompt(id, keepPlaying) { /* 写回当前、载入目标、可选续播 */ }
// 播放中上下篇
function switchPrompt(dir) {
  var idx = library.findIndex(p => p.id === activeId);
  idx = (idx + dir + library.length) % library.length;
  loadPrompt(library[idx].id, true);
}

删除用「二次确认」而非 `confirm()——这是移动端的关键坑(见第四节)。

5. PWA 封装(装到桌面 + 离线)

单文件 HTML 之外,再加 manifest.json + sw.js 即可:

代码语言:js
复制
if ("serviceWorker" in navigator) {
  window.addEventListener("load", () => navigator.serviceWorker.register("sw.js"));
}
let deferredPrompt = null;
window.addEventListener("beforeinstallprompt", e => {
  e.preventDefault(); deferredPrompt = e;
  installBtn.style.display = "inline-block";   // 触发「装到桌面」按钮
});

sw.jscache-first 策略缓存 HTML/图标,部署到 HTTPS 后,手机「添加到主屏幕」即成独立 App,可完全离线。


四、踩坑复盘(三个真实坑)

坑 1:confirm() 在移动端 WebView 被静默拦截

最初的「删除文案」用了浏览器原生 confirm() 二次确认。结果在部分 App 内置浏览器 / 系统 WebViewconfirm 根本不弹,点了毫无反应,表现为「删不了」。

修复:改用「两次点击确认」——第一次点按钮变红显示「确认删?」,3 秒内再点才真删,纯前端状态机,不依赖任何原生对话框。

代码语言:js
复制
if (!delArmed) { delArmed = true; this.textContent = "确认删?"; /* 3s 后复位 */ return; }
// 第二次点击才执行 splice 删除

坑 2:滚动方向写反,只显示最后几个字

布局用 flex-end 把内容底部贴屏幕底,导致一开播就停在稿子末尾,正向滚动逻辑完全错乱。

修复:去掉 flex 对齐,改为 translateY(startOff - pos),让内容从底部 92% 处自然升入。详见第三节第 1 点。

坑 3:部分 IM 直接发 .html 可能被拦

teleprompter.html 当文件发某些即时通讯工具,偶尔会被安全策略拦截。兜底方案:同时打一个 .zip(压缩包通常不拦),手机解压后得 HTML;或在内网用 http://<电脑IP>:<端口>/teleprompter.html 直接访问。


五、分发与打包路线

方式

体验

适用

浏览器直接打开.html

单个文件即可运行,断网可用

本地自用

局域网直连

实时调试最新版本

同 WiFi 下多设备查看

静态托管 + 添加到主屏幕

接近原生 App 体验,可离线

需要「安装到桌面」

PWA → Bubblewrap(TWA)

生成 APK 安装包

需要上架应用商店

APK 说明:本地没有 Android SDK,直接打 APK 不现实。可先把 PWA 部署到 HTTPS 静态托管,再用 Google 官方的 Bubblewrap 工具生成 Trusted Web Activity(TWA)壳 APK,离线缓存照常生效。第三方 PWA-to-APK 在线服务也可作为备选,但需注意隐私与签名安全。


六、总结

一个零依赖、可离线、跨平台的手机提词器,本质就是「单文件 HTML + 几个核心函数」:

  • 滚动用 translateY + requestAnimationFrame
  • 分镜用空行切分 + 节点跳转
  • 多文案用 localStorage 数组 + 二次确认删除
  • 想要 App 体验就加 PWA

源码就一个 HTML 文件,约 23KB,可自由改配色、加功能(如虚拟滚动支持超长文本、远程手机遥控滚动)。这套「单文件 HTML + PWA」的范式在做其他移动端轻量工具时同样适用。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 从 0 到 1 打造离线可用的手机提词器:单文件 HTML + PWA 实战
    • 导语
    • 一、技术选型:为什么是「单文件 HTML」
    • 二、整体架构:编辑界面 + 播放界面
    • 三、核心实现逐个讲
      • 1. 正向滚动(最容易写反的地方)
      • 2. 分镜时间轴(空行即分镜)
      • 3. 横竖屏自适应
      • 4. 多文案库(localStorage 数组存储)
      • 5. PWA 封装(装到桌面 + 离线)
    • 四、踩坑复盘(三个真实坑)
      • 坑 1:confirm() 在移动端 WebView 被静默拦截
      • 坑 2:滚动方向写反,只显示最后几个字
      • 坑 3:部分 IM 直接发 .html 可能被拦
    • 五、分发与打包路线
    • 六、总结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档