起因
事情的起因是调试 IOS 手机下播放服务器接口返回的 mp4 文件流失败。对于没调试过移动端和 Safari 的我来说着实费了些功夫,网上和AI也没有讲明白。好在最终大概理清楚了,在这里整理出来供有缘人参考。
问题
因为直接用 IOS 手机的浏览器打开页面去播放也能复现,所以问题出现在 IOS上的 Safari 浏览器上。
问题主要分两类:
- 一、视频能播放,但是不主动设置 poster,就不显示默认的 poster
- 二、视频不能播放
首先文件是没有问题的,是主流浏览器都支持的视频编码格式为 H.264(AVC) 的 .mp4
文件,由文件格式导致的播放失败问题,网上很多,这里就不赘述了。
问题复现和解决
因为其他浏览器可以正常播放,所以用来对比差异时,我直接在PC端(Edge)查看信息,而 Safari 是在手机上访问项目页面,以及局域网下访问本地运行的 web 服务页面。
用到的工具如下:
- Live Server 用来运行本地 web 页面
- node + nodemon + Express 用来运行文件服务,模拟后端接口,nodemon 方便实时同步运行修改内容
- video.js 播放视频的插件,直接用 CDN。
- vConsole 移动端查看控制台,直接用 CDN
编写简单 demo
排查问题的时候我是一点一点将实现方式还原到最原始的方式:
- 后端接口 + video.js
- 后端接口 + video标签
- 本地运行的接口 + video标签
- 本地文件 + video标签
所以这里再梳理,就可以反向从最简单的方式排查,在本地写个用本地视频文件 + video标签的demo,为了后续排查方便,将事件日志也打印出来。
视频资源我用的
https://vjs.zencdn.net/v/oceans.mp4
下载到本地,不过它的开头是黑屏淡入的,为了方便区分 “不显示poster”,我将开头的几秒黑屏裁剪掉了。
也可以用安卓手机录制一个,默认就是支持播放的 mp4 文件。(PS:别用QQ录屏,编码不对)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
<script>
var vConsole = new window.VConsole()
</script>
<style>
#video {
width: 480px;
height: 25vh;
max-width: 100%;
/* 背景色用于在视频不显示poster时查看占位 */
background: pink;
border: 4px solid pink;
}
</style>
</head>
<body>
<div class="video-box">
<video id="video" src="oceans.mp4" controls preload="auto" playsinline="true" muted></video>
</div>
<div id="log">
<h2>日志:方便手机上的 Safari 查看</h2>
<div class="log__content"></div>
</div>
<script>
// 打印日志
function log(msg) {
// 页面上打印日志
const logContentEl = document.querySelector('.log__content')
const pEl = document.createElement('p')
pEl.textContent = msg
logContentEl.appendChild(pEl)
// 控制台打印日志
console.log(msg)
}
const video = document.getElementById('video')
// 主要关心的事件
const eventNames = [
{ name: 'abort', desc: '当音频/视频的加载已放弃时触发' },
{ name: 'canplay', desc: '当浏览器可以开始播放音频/视频时触发' },
{ name: 'canplaythrough', desc: '当浏览器预计能够在不因缓冲而停顿的情况下持续播放指定的音频/视频时触发' },
{ name: 'durationchange', desc: '当音频/视频的时长已更改时触发' },
{ name: 'error', desc: '当在音频/视频加载期间发生错误时触发' },
{ name: 'loadeddata', desc: '当浏览器已加载音频/视频的当前帧时触发' },
{ name: 'loadedmetadata', desc: '当浏览器已加载音频/视频的元数据时触发' },
{ name: 'loadstart', desc: '当浏览器开始查找音频/视频时触发' },
{ name: 'play', desc: '当音频/视频已开始或不再暂停时触发' },
{ name: 'playing', desc: '当音频/视频在因缓冲而暂停或停止后已就绪时触发' },
{ name: 'progress', desc: '当浏览器正在下载音频/视频时触发' },
{ name: 'timeupdate', desc: '当音频/视频的播放位置发生改变时触发' },
{ name: 'waiting', desc: '当视频由于需要缓冲下一帧而停止,等待时触发' }
]
// 注册video事件监听器
eventNames.forEach(v => {
video.addEventListener(v.name, () => {
// 打印日志
log(`【readyState: ${video.readyState}】 ${v.name}: ${v.desc}`)
})
})
</script>
</body>
</html>
preload
默认是metadata
,和auto
的日志结果一样。为了排除一些可能性,我将其设置了auto
。
playsinline="true"
防止 IOS 播放视频时自动打开全屏。
问题1 不显示预览图
demo 页面加载后发现,Safari 浏览器没有显示视频的预览图:
Edge 显示了预览图:
通过日志发现,Safari 加载完元数据后(loadedmetadata
)就不会继续加载了。
我们知道,视频的预览图就是 video 标签的 poster 属性,当 poster 有值时就会显示指定的图片,当 poster 没有值时,浏览器就会自动处理,而不同浏览器的处理方式也不一样,可能会有这几种情况:
- 如果配置了预加载,显示加载后的第一帧,或第二、三桢。
- 什么都不显示,或显示默认的播放器背景样式
从日志可以看到,Safari 只加载了元数据,并没有加载画面。查看官方文档,得到了解答:
再看 poster 的说明:
既然如此,那只能提供一个 poster 来解决了。
问题2 视频不能播放
问题复现
播放 demo 示例里的视频,是可以正常播放的。
将播放方式改成 video.js + 本地文件,播放是正常的。代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://unpkg.com/video.js@7.10.2/dist/video-js.min.css" rel="stylesheet" />
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
<script>
var vConsole = new window.VConsole()
</script>
<style>
#video {
width: 480px;
max-width: 100%;
height: 25vh;
/* 背景色用于在视频不显示poster时查看占位 */
background: pink;
border: 4px solid pink;
}
</style>
</head>
<body>
<video id="video" class="video-js" controls preload="auto" playsinline="true" muted>
<source src="oceans.mp4" type="video/mp4" />
</video>
<div id="log">
<h3>日志:方便手机上的 Safari 查看</h3>
<div class="log__content"></div>
</div>
<script src="https://unpkg.com/video.js@7.10.2/dist/video.min.js"></script>
<script>
// 打印日志
function log(msg) {
// 页面上打印日志
const logContentEl = document.querySelector('.log__content')
const pEl = document.createElement('p')
pEl.textContent = msg
logContentEl.appendChild(pEl)
// 控制台打印日志
console.log(msg)
}
// 主要关心的 videojs 事件
const eventNames = [
{ name: 'abort', desc: '当音频/视频的加载已放弃时触发' },
{ name: 'canplay', desc: '当浏览器可以开始播放音频/视频时触发' },
{ name: 'canplaythrough', desc: '当浏览器预计能够在不因缓冲而停顿的情况下持续播放指定的音频/视频时触发' },
{ name: 'durationchange', desc: '当音频/视频的时长已更改时触发' },
{ name: 'error', desc: '当在音频/视频加载期间发生错误时触发' },
{ name: 'loadeddata', desc: '当浏览器已加载音频/视频的当前帧时触发' },
{ name: 'loadedmetadata', desc: '当浏览器已加载音频/视频的元数据时触发' },
{ name: 'loadstart', desc: '当浏览器开始查找音频/视频时触发' },
{ name: 'play', desc: '当音频/视频已开始或不再暂停时触发' },
{ name: 'playing', desc: '当音频/视频在因缓冲而暂停或停止后已就绪时触发' },
{ name: 'progress', desc: '当浏览器正在下载音频/视频时触发' },
{ name: 'timeupdate', desc: '当音频/视频的播放位置发生改变时触发' },
{ name: 'waiting', desc: '当视频由于需要缓冲下一帧而停止,等待时触发' }
]
const player = videojs('video')
player.ready(() => {
// 注册video事件监听器
eventNames.forEach(v => {
player.on(v.name, () => {
// 打印日志
log(`【readyState: ${player.readyState()}】 ${v.name}: ${v.desc}`)
})
})
})
</script>
</body>
</html>
然后换成后端接口地址,Edge 可以播放,但是 Safari 就失败了。
video 标签方式:
video.js 方式:报错 The media could not be loaded, either because the server or network failed or because the format is not supported.
我肯定不是服务器网络故障,也不是文件格式不支持,所以原因只能是接口了。
本地模拟接口
为了不麻烦后端同事,我只能在本地搭建一个服务器,模拟项目接口。
搭建服务
创建 app.js
文件
const express = require('express')
const fs = require('fs')
const path = require('path')
const app = express()
app.get('/', (req, res) => {
res.send('Stupid IOS')
})
app.listen(3000, () => {
const ip = 192.169.3.7 // 我的局域网ip
console.log(`server is running on http://${ip}:3000`)
})
# 安装依赖
npm i express
# 已安装 nodemon,所以直接使用
nodemon app.js
访问 http://192.169.3.7:3000/
。把 oceans.mp4
视频文件放到 app.js
同目录下,后面就编写接口就行。
方式1 使用 express 封装好的方法返回文件流
// 方式1: 使用封装好的方法返回文件流
app.get('/type1/:file', (req, res) => {
const fileName = req.params.file
const filePath = path.join(__dirname, fileName)
res.sendFile(filePath)
})
文件地址:http://192.169.3.7:3000/type1/oceans.mp4
video标签和 video.js 播放都正常。
方式2 手动读取全部文件流并返回
同样是接口,本地模拟的正常,项目接口不能播放,那就继续更细致的模拟,手动读取文件流,设置相同的响应头。
查看项目接口的响应头,排除一些范围,只模拟有可能影响的:
// 方式2: 手动读取全部文件流并返回
app.get('/type2/:file', (req, res) => {
const fileName = req.params.file
const filePath = path.join(__dirname, fileName)
// 打印请求头
console.log(req.headers)
fs.stat(filePath, (err, stats) => {
if (err) {
return res.status(404).send('file not found')
}
// 设置响应头
res.set({
'Accept-Ranges': 'bytes', // 支持 Range 请求
'Cache-Control': 'no-cache, no-store', // 不缓存
'Content-Type': 'video/mp4;charset=UTF-8',
'Content-Range': `bytes 0-${stats.size - 1}/${stats.size}`,
'Content-Length': stats.size,
'Content-Disposition': 'attachment;filename="oceans.mp4"',
ETag: `"${stats.ino.toString()}-${stats.size.toString()}-${Date.now().toString()}"`,
'Last-Modified': stats.mtime.toUTCString(),
'Pragma': 'no-cache'
})
const stream = fs.createReadStream(filePath)
stream.pipe(res)
})
})
文件地址:http://192.169.3.7:3000/type2/oceans.mp4
问题依旧存在,于是查看官方文档,找到一段说明:
Range 请求就是范围请求或分块传输,客户端通过请求头
Range
指定当前请求想要获取的数据子节范围,服务器根据这个范围,读取文件流,并将读取的内容返回给客户端。
通常,服务器会返回 206 状态码,表示范围请求的响应结果。并且需要在响应头中包含 Content-Range 字段,指明实际返回的数据范围,以及整个资源的总大小。
可是我加了支持 range 请求的响应头啊:'Accept-Ranges': 'bytes'
。
继续看文档,下面介绍了如何确认服务器是否支持 range 请求:
大概意思就是主动发送一个指定范围 100 bytes 的请求,看返回的数据是100 bytes,那就是支持,如果返回了整个文件,那就是不支持。
查看之前服务器中打印的请求头,Range
请求头的值:
- Edge 是
0-
,表示获取整个资源 - Safari 是
0-1
:表示获取位置0
和1
的子节的资源,注意可不是从开头到第1个子节,这个范围的请求数是2个子节。
因为我每次都返回的完整的文件流,没有按照 Safari 的要求范围处理,所以属于不支持。
看来仅仅配置响应头是不行的,还要正确处理请求头中的指定范围。
方式3 手动读取指定范围的文件流并返回-分块传输
原来 Safari 会先发送一个获取范围为 bytes=0-1
的请求,以测试服务器是否支持 range 请求。
于是我手动改了下响应头,去掉那些没有影响的,还是返回整个文件流:
// 设置响应头
res.set({
'Accept-Ranges': 'bytes', // 支持 Range 请求
'Cache-Control': 'no-cache, no-store', // 不缓存
'Content-Type': 'video/mp4;charset=UTF-8',
'Content-Range': `bytes 0-1/${stats.size}`,
'Content-Length': stats.size,
})
看来仅仅伪造响应头还是不行,还要返回正确大小的文件流,那就编写分块传输的接口:
// 方式3: 手动读取指定范围的文件流并返回-分块传输
app.get('/type3/:file', (req, res) => {
const fileName = req.params.file
const filePath = path.join(__dirname, fileName)
// 查看请求头的范围
console.log(req.headers.range)
fs.stat(filePath, (err, stats) => {
if (err) {
return res.status(404).send('file not found')
}
// 设置响应头
res.set({
'Accept-Ranges': 'bytes', // 支持 Range 请求
'Cache-Control': 'no-cache, no-store', // 不缓存
'Content-Type': 'video/mp4'
})
const range = req.headers.range
let parts
let start = 0
let end = stats.size - 1
if (range) {
parts = range.replace(/bytes=/, '').split('-')
start = parseInt(parts[0], 10)
end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1
}
if (start >= stats.size || end > stats.size) {
// 如果Range请求超出文件大小,返回416状态码
return res.status(416).end()
}
if (start >= 0 && end >= 0) {
// 处理 Range 请求
res.set({
'Content-Range': `bytes ${start}-${end}/${stats.size}`,
'Content-Length': end - start + 1
})
// 部分内容状态码,返回200浏览器也能正常处理
res.status(206)
}
const stream = fs.createReadStream(filePath, { start, end })
stream.pipe(res)
})
})
终于视频可以正常播放了,video.js 也可以播放。
Safari 的校验还挺严格,如果服务器正确处理了这个2子节的请求,Safari 就会开始正式发送正常范围的请求。
服务器的日志可以体现出来:
bytes=0-1
bytes=0-51883958
bytes=196608-51883958
bytes=458752-51883958
最终查看了后端代码,果然接口没有处理 range 请求,在修改逻辑后,功能终于正常。
继续伪造 range 接口
为了搞清楚 Safari 的校验到底有多严格,我再次尝试模拟了一下第一次请求的响应:
const range = req.headers.range
let parts
let start = 0
let end = stats.size - 1
// 增加逻辑 start-----------------------------------------
if (range === 'bytes=0-1') {
// 设置响应头
res.set({
'Accept-Ranges': 'bytes', // 支持 Range 请求
'Cache-Control': 'no-cache, no-store', // 不缓存
'Content-Type': 'video/mp4;charset=UTF-8',
'Content-Range': `bytes 3-3/${stats.size}`, // 随便写个范围
'Content-Length': 2
})
const stream = fs.createReadStream(filePath, { start:100, end:200 }) // 随便获取个范围,但不能少于2
stream.pipe(res)
return res.status(206) // 随便返回个状态码
}
// 增加逻辑 end-----------------------------------------
if (range) {
parts = range.replace(/bytes=/, '').split('-')
start = parseInt(parts[0], 10)
end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1
}
再次提醒,bytes=0-1
表示请求的位置是0
和 1
的子节,是2个子节,而不是 1-0=1
的子节数量。
不断测试下,发现只要满足这几个要求,Safari 就认为接口支持 range 请求:
Content-Length
要正确。Content-Range
的范围要合理。- 返回了不少于
Range
请求头要求大小的文件流数据。
而下面这几点,不影响 Safari 的校验结果:
Content-Range
的范围和Range
的指定范围不一样- 读取的数据范围和
Range
的指定范围不一样 - 返回任意状态码
总结
- IOS 上的 Safari 不支持 video 预加载(preload),浏览器不会自动提取帧画面作为默认的 poster 预览图
- Safari 上使用 video 播放视频,必须支持并正确处理 Range 范围请求,浏览器会先发送
bytes=0-1
范围的请求来测试服务器是否支持 Range 请求,如果校验成功,就会继续发送正常范围的Range
请求。否则不再请求资源。