用 Vimeo Player.js 统计真实的观看进度:记录看过的每一秒,拖动进度条跳过的部分不算;达到 25/50/75/90% 时各记录一次里程碑;进度保存在 localStorage,下次打开时显示“从上次位置继续”。统计逻辑写成单独的模块,用 Node 测试。
先看清成品
统计模块用
node --test跑了 3 个测试,全部通过。页面本身没能在本机验证:用 Playwright 驱动无头 Chrome 打开页面后,Vimeo 播放器 iframe 里出现了 Cloudflare Turnstile 人机验证(控制台报[Cloudflare Turnstile] Error: 600010),播放器一直没有就绪,e2e.py在 30 秒时超时。我没有尝试绕过这个验证。请在普通浏览器里手动打开验证。
注意:Vimeo 各会员等级的价格、存储和上传额度以官方价格页为准,本文不列数字(官方上传指南里有额度表)。只上传和嵌入你有权使用的视频;不要共享会员账号或 access token;不要绕过隐私设置、密码或禁止嵌入的限制。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
tracker.js |
按整秒去重,计算百分比和里程碑 |
index.html |
播放器、进度显示、断点续看 |
e2e.py |
端到端测试脚本(本机被人机验证拦住了) |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| 浏览器 | 近期版本的 Chrome、Edge、Firefox 或 Safari | 页面要通过 http 服务打开,file:// 下 ES module 无法加载 |
| Node.js | 18 以上(本机 v22) | 测试 tracker.js |
| 视频 | 允许嵌入的 Vimeo 视频 | 示例用的是 Blender 的 Big Buck Bunny(1084537) |
文件结构
30-vimeo-B-src/
├── package.json
├── tracker.js
├── tracker.test.mjs
├── index.html
└── e2e.py
第 1 步:统计逻辑
tracker.js:
// 观看进度统计:把看过的整秒记进 Set,拖动进度条跳过的部分不算“看过”。
export function createTracker(duration) {
const seen = new Set();
return {
add(seconds) { const s = Math.floor(seconds); if (s >= 0 && s < duration) seen.add(s); },
percent() { return duration > 0 ? Math.round((seen.size / Math.ceil(duration)) * 100) : 0; },
watchedSeconds() { return seen.size; },
milestones(list = [25, 50, 75, 90]) { const p = this.percent(); return list.filter((m) => p >= m); },
toJSON() { return [...seen]; },
load(arr) { arr.forEach((s) => this.add(s)); },
};
}
timeupdate 在播放过程中会反复触发(频率以 Player SDK 文档为准)。用整秒的 Set 来记录,重复观看不会重复计数,跳过的部分也不会被算进去。
第 2 步:测试
tracker.test.mjs:
import test from "node:test";
import assert from "node:assert";
import { createTracker } from "./tracker.js";
test("拖动跳过的部分不计入", () => {
const t = createTracker(100);
for (let s = 0; s < 10; s += 0.25) t.add(s); // 连续看了 0–10 秒
t.add(80.5); // 直接跳到 80 秒
assert.strictEqual(t.watchedSeconds(), 11);
assert.strictEqual(t.percent(), 11);
});
test("重复观看不重复计数,里程碑", () => {
const t = createTracker(8);
for (let r = 0; r < 3; r++) for (let s = 0; s < 6; s++) t.add(s);
assert.strictEqual(t.percent(), 75);
assert.deepStrictEqual(t.milestones(), [25, 50, 75]);
});
test("越界和恢复", () => {
const t = createTracker(10); t.add(-1); t.add(10); t.load([1, 2, 2]);
assert.deepStrictEqual(t.toJSON(), [1, 2]);
});
node --test # pass 3, fail 0
第 3 步:页面
index.html:
<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>Vimeo 观看进度</title>
<style>body{font-family:sans-serif;max-width:720px;margin:24px auto}.ratio{position:relative;padding-top:56.25%}.ratio iframe{position:absolute;inset:0;width:100%;height:100%}</style>
</head>
<body>
<div class="ratio">
<iframe id="v" src="https://player.vimeo.com/video/1084537?dnt=1" allow="autoplay; fullscreen; picture-in-picture" title="Big Buck Bunny"></iframe>
</div>
<p>已观看 <b id="pct">0</b>%(<span id="sec">0</span> 秒)· 已达成:<span id="ms">-</span></p>
<button id="resume" hidden>从上次位置继续</button>
<script src="https://player.vimeo.com/api/player.js"></script>
<script type="module">
import { createTracker } from "./tracker.js";
const player = new Vimeo.Player(document.getElementById("v"));
window.__player = player;
const id = await player.getVideoId();
const KEY = "vimeo-progress-" + id;
const duration = await player.getDuration();
const tracker = createTracker(duration);
const saved = JSON.parse(localStorage.getItem(KEY) || "{}");
if (saved.seen) tracker.load(saved.seen);
const sent = new Set(saved.sent || []);
function render() {
document.getElementById("pct").textContent = tracker.percent();
document.getElementById("sec").textContent = tracker.watchedSeconds();
document.getElementById("ms").textContent = [...sent].join("% / ") + (sent.size ? "%" : "-");
}
if (saved.last > 5) {
const btn = document.getElementById("resume");
btn.hidden = false;
btn.onclick = () => { player.setCurrentTime(saved.last); btn.hidden = true; };
}
let lastSave = 0;
player.on("timeupdate", ({ seconds }) => {
tracker.add(seconds);
for (const m of tracker.milestones()) if (!sent.has(m)) { sent.add(m); console.log("milestone", m); }
if (Date.now() - lastSave > 2000) { // 每 2 秒存一次,避免频繁写入
lastSave = Date.now();
localStorage.setItem(KEY, JSON.stringify({ seen: tracker.toJSON(), sent: [...sent], last: seconds }));
}
render();
});
player.on("ended", () => console.log("ended"));
window.__tracker = tracker; // 方便测试
render();
</script>
</body>
</html>
要点:
new Vimeo.Player(iframe)可以直接接管已有的 iframe;getVideoId()、getDuration()返回 Promise。- 地址里的
dnt=1让播放器不记录会话数据。按 oEmbed 文档的说法,这样也会屏蔽视频的统计数据,你需要自己权衡。 - 每 2 秒写一次 localStorage,不要每次
timeupdate都写。 - 里程碑现在只打印到控制台,要上报统计时,把
console.log换成你自己的接口。
第 4 步:打开(本机未在真人浏览器中执行)
cd 30-vimeo-B-src
python3 -m http.server 8000
# 浏览器打开 http://localhost:8000/
检查结果:播放 30 秒后显示约 5%(视频时长 597 秒,来自 oEmbed);拖到中间再播放,百分比只增加实际播放的部分;刷新页面后出现“从上次位置继续”按钮。
调试示例
无头浏览器里播放器一直不就绪
e2e.py 第一次运行时,在 wait_for_function("window.__player") 处等了 60 秒后超时。原因是 window.__player 的赋值写在两个 await 之后,播放器没有就绪,就一直执行不到。改成创建播放器后立即赋值,再运行一次,打开控制台日志后看到了真正的原因:
console: [Cloudflare Turnstile] Error: 600010.
console: Connecting to 'data:,' violates the following Content Security Policy directive: "connect-src 'self' https://hagen.challenges.cloudflare.com ...
Vimeo 播放器在这个无头环境里先弹出了人机验证。这属于平台的防护措施,不是代码的问题。所以本文只把统计逻辑做了单元测试,页面部分请在普通浏览器里人工验证。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
Vimeo is not defined |
player.js 没加载成功 | 检查网络,并确认 script 标签写在 module 脚本之前 |
| 播放器显示“无法播放” | 视频禁止嵌入,或限制了嵌入域名 | 换一个视频,或者在 Vimeo 后台把你的域名加进允许列表 |
| 百分比到不了 100 | 最后一秒的 timeupdate 不一定会触发 |
用 90% 作为“看完” |
| 刷新后进度没了 | 用 file:// 打开或隐私模式 |
用 http 服务打开 |
模板规则
- 统计逻辑和播放器代码分开,先测逻辑。
- “看过多少”按去重后的秒数计算,不用当前播放位置。
- 写入存储要节流。
- 平台的人机验证不要绕过,碰到了就改成人工验证。
项目资源
| 用途 | 地址 |
|---|---|
| Player SDK 概览 | https://developer.vimeo.com/player/sdk |
| Player SDK 参考(事件、方法) | https://developer.vimeo.com/player/sdk/reference |
| player.js 源码 | https://github.com/vimeo/player.js |
| 嵌入参数(oEmbed 文档) | https://developer.vimeo.com/api/oembed/videos |
Player.js 提供事件和 Promise 接口,统计逻辑可以完全由你自己决定。把“看过的秒数”和“播放到哪里”分开计算,统计的完成率才准确。