一段总长 60 秒的音频,播放到 59.8 秒时,界面把百分比四舍五入成了 100%。如果这时直接触发"播放完成",就把一个显示数字当成了播放器的结束事实。
以米米商聊为背景,已整理的手机端资料包含语音消息。本文借这一功能讨论浏览器音频进度的展示设计,不断言产品采用下文的百分比、时长格式或音频组件,也不推断原生 App 的内部实现。所有输入均为合成快照,没有加载真实语音或用户音频。
1. 播放位置、总时长和结束状态分别表示什么
浏览器媒体元素的 currentTime 表示当前播放位置,duration 表示媒体时长,单位都是秒。位置与时长可以用于计算显示比例,但结束状态还需要依据播放器自身提供的信息。
在真实浏览器接入中,可以参考 ended 属性和 ended 事件的语义。不能把界面经过舍入的百分比重新当成结束判据。
例如,59.8 ÷ 60 × 100 约为 99.67,显示为整数就是 100%。这时剩余时间仍非零。另一方面,位置跳到末尾附近、暂停或更换资源,也不能仅凭一个比例说明发生了哪种播放行为。
| 信息 | 本文用来做什么 | 不应直接推断的事情 |
|---|---|---|
currentTime |
计算已播放位置的显示值 | 用户已经听完全部内容 |
duration |
展示总时长及计算比例 | 一定已经取得有限总时长 |
| 舍入后的百分比 | 显示近似进度 | 媒体已经触发结束 |
播放器的 ended 状态 |
提供结束状态输入 | 可以替代资源与事件绑定检查 |

2. 元数据尚未就绪时,不要直接做除法
根据 MDN 的 duration 文档,尚无媒体数据时,duration 可能为 NaN;没有已知时长的媒体可能返回 Infinity。它们不应被格式化成正常的有限总时长。
本例约定:总时长为 NaN 或正无穷时,显示占位文本 --:--,百分比返回 null,表示不能据此画出确定的比例。总时长为 0 时显示 0:00,但也不执行除零运算。
这不是把未知时长当成零。两者的文本结果和含义不同:一个尚无可用的有限总量,一个是输入明确给出的零值。页面需要加载、不可用或错误提示时,还应结合实际媒体事件处理,本文不凭时长一个字段猜测错误原因。
3. 一个只生成显示快照的函数
下面函数接收具名数值与布尔结束状态,输出已播放时间、总时长、整数百分比和原样保留的结束状态。它不读取或控制播放器。
javascript
function audioProgressView(input) {
if (input === null || typeof input !== "object" || Array.isArray(input)) {
throw new TypeError("media snapshot must be an object");
}
const { currentTime, duration, ended } = input;
const validSeconds = value => typeof value === "number"
&& Number.isFinite(value) && value >= 0
&& value <= Number.MAX_SAFE_INTEGER;
if (!validSeconds(currentTime)) throw new RangeError("invalid currentTime");
if (typeof duration !== "number") throw new TypeError("invalid duration type");
if (typeof ended !== "boolean") throw new TypeError("ended must be boolean");
const unknown = Number.isNaN(duration) || duration === Infinity;
if (!unknown && !validSeconds(duration)) throw new RangeError("invalid duration");
function clock(value) {
const whole = Math.floor(value);
const minutes = Math.floor(whole / 60);
const seconds = String(whole % 60).padStart(2, "0");
return `${minutes}:${seconds}`;
}
if (unknown) {
return { elapsed: clock(currentTime), total: "--:--", percent: null, ended };
}
const shown = Math.min(currentTime, duration);
return {
elapsed: clock(shown),
total: clock(duration),
percent: duration === 0 ? null : Math.round(shown / duration * 100),
ended
};
}
本例以分钟累计显示,超过一小时可以出现 60:01,不会回绕成某个钟表时刻。时间文字向下取整,百分比则四舍五入;它们使用不同的展示精度,不应该反向决定播放器状态。
如果当前位置超过有限总时长,只在显示投影中裁剪到总时长。输入对象不变,也不会把裁剪值写回媒体元素。这样做是示例的显示选择,不是自动修复媒体状态。
数值上限 Number.MAX_SAFE_INTEGER 是本例的输入边界,不是产品语音时长限制。数字字符串、负数、非有限播放位置或非布尔结束状态会被拒绝,避免默默猜测数据含义。
4. 直接验证"100% 但尚未结束"的反例
将下面演示与上一段放在同一个 JavaScript 文件中执行。它只处理合成输入,不发起任何音频请求。
javascript
const nearEnd = audioProgressView({ currentTime: 59.8, duration: 60, ended: false });
console.assert(nearEnd.percent === 100);
console.assert(nearEnd.elapsed === "0:59");
console.assert(nearEnd.total === "1:00");
console.assert(nearEnd.ended === false);
const loading = audioProgressView({ currentTime: 0, duration: NaN, ended: false });
console.assert(loading.total === "--:--" && loading.percent === null);
const zero = audioProgressView({ currentTime: 0, duration: 0, ended: false });
console.assert(zero.total === "0:00" && zero.percent === null);
const finished = audioProgressView({ currentTime: 60, duration: 60, ended: true });
console.assert(finished.percent === 100 && finished.ended === true);
console.log(nearEnd);
第一项已经显示 100%,但函数仍保留输入的 ended: false。最后一项保留 ended: true,依据来自调用方提供的结束状态,不是比例本身。
配套验证覆盖未知时长、零时长、正常进度、舍入边界、超出时长、累计分钟、非法输入和对象不变性,并检查 500 个连续合成快照的比例范围及单调显示。它只验证这个纯函数,不是编解码、音频播放或真实客户端验收。
5. 结束状态与进度更新需要分别接入
timeupdate 可以用于刷新播放位置,但它的触发频率会受到系统负载等因素影响,不应被假设为固定频率的精确计时器,见 MDN 的 timeupdate 说明。更频繁地刷新一个经过舍入的百分比,也不会使它变成结束事实。
接入时,应从当前媒体实例取得相应状态,再把同一资源的快照交给显示函数。资源切换后,不能把旧音频的结束回调应用到新消息;播放、暂停、等待缓冲与错误,也不能都用 ended 一个字段表达。
循环播放还需要单独约定。MDN 对 ended 事件说明:当 loop 为真且播放速率非负时,该事件不会按普通单次播放的方式触发。不能把"每段音频最终总会收到一次 ended 事件"写成通用保证。
| 需要处理的场景 | 本函数的边界 |
|---|---|
| 媒体时长还未知 | 只提供占位文本与空比例 |
| 播放暂停或缓冲 | 没有实现这些状态的判定 |
| 资源切换或组件卸载 | 没有管理监听器及资源身份 |
| 循环播放与自动续播 | 没有实现播放控制策略 |
| 输入状态彼此矛盾 | 不验证完整媒体状态历史 |
如果调用方传入了错误的结束状态,函数也无法替它证明播放行为。测试展示投影与验收事件绑定应分别进行,不能把其中一项通过当成另一项已经完成。
6. 验收时同时看数字和实际行为
先核对单位和输入来源:当前位置与总时长是否都是秒,是否属于同一个媒体资源。再分别检查未知时长、零时长和有限时长下的文本与比例,最后通过实际播放验收结束回调、切换资源和循环策略。
对于"用户是否已经听完"这样的业务判断,还需另行定义规则。播放位置可能被拖动,媒体元素到达末尾也不等于用户完整听到了每一段声音,不能直接把展示层的 100% 当成收听行为的证明。
时间文字、百分比和结束状态各自表达一层信息。让显示函数只负责显示,让播放器状态与事件负责提供事实,才能避免一个看似方便的 percent === 100 判断提前改变语音消息的交互。
本文由 AI 辅助整理,独立示例经过本地合成验证。产品背景只引用已整理的手机端语音消息资料;浏览器媒体示例不代表产品时长格式、原生音频组件或内部实现。