IOS safari 播放 mp4 遇到的坎儿

起因

事情的起因是调试 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

排查问题的时候我是一点一点将实现方式还原到最原始的方式:

  1. 后端接口 + video.js
  2. 后端接口 + video标签
  3. 本地运行的接口 + video标签
  4. 本地文件 + video标签

所以这里再梳理,就可以反向从最简单的方式排查,在本地写个用本地视频文件 + video标签的demo,为了后续排查方便,将事件日志也打印出来。

视频资源我用的 https://vjs.zencdn.net/v/oceans.mp4 下载到本地,不过它的开头是黑屏淡入的,为了方便区分 "不显示poster" ,我将开头的几秒黑屏裁剪掉了。

也可以用安卓手机录制一个,默认就是支持播放的 mp4 文件。(PS:别用QQ录屏,编码不对)

html 复制代码
<!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 没有值时,浏览器就会自动处理,而不同浏览器的处理方式也不一样,可能会有这几种情况:

  1. 如果配置了预加载,显示加载后的第一帧,或第二、三桢。
  2. 什么都不显示,或显示默认的播放器背景样式

从日志可以看到,Safari 只加载了元数据,并没有加载画面。查看官方文档,得到了解答:

再看 poster 的说明:

既然如此,那只能提供一个 poster 来解决了。

问题2 视频不能播放

问题复现

播放 demo 示例里的视频,是可以正常播放的。

将播放方式改成 video.js + 本地文件,播放是正常的。代码如下:

html 复制代码
<!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 文件

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`)
})
base 复制代码
# 安装依赖
npm i express
# 已安装 nodemon,所以直接使用
nodemon app.js

访问 http://192.169.3.7:3000/。把 oceans.mp4 视频文件放到 app.js 同目录下,后面就编写接口就行。

方式1 使用 express 封装好的方法返回文件流
js 复制代码
// 方式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 手动读取全部文件流并返回

同样是接口,本地模拟的正常,项目接口不能播放,那就继续更细致的模拟,手动读取文件流,设置相同的响应头。

查看项目接口的响应头,排除一些范围,只模拟有可能影响的:

js 复制代码
// 方式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:表示获取位置01 的子节的资源,注意可不是从开头到第1个子节,这个范围的请求数是2个子节。

因为我每次都返回的完整的文件流,没有按照 Safari 的要求范围处理,所以属于不支持。

看来仅仅配置响应头是不行的,还要正确处理请求头中的指定范围。

方式3 手动读取指定范围的文件流并返回-分块传输

原来 Safari 会先发送一个获取范围为 bytes=0-1 的请求,以测试服务器是否支持 range 请求。

于是我手动改了下响应头,去掉那些没有影响的,还是返回整个文件流:

js 复制代码
    // 设置响应头
    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,
    })

看来仅仅伪造响应头还是不行,还要返回正确大小的文件流,那就编写分块传输的接口:

js 复制代码
// 方式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 就会开始正式发送正常范围的请求。

服务器的日志可以体现出来:

js 复制代码
bytes=0-1
bytes=0-51883958
bytes=196608-51883958
bytes=458752-51883958

最终查看了后端代码,果然接口没有处理 range 请求,在修改逻辑后,功能终于正常。

继续伪造 range 接口

为了搞清楚 Safari 的校验到底有多严格,我再次尝试模拟了一下第一次请求的响应:

js 复制代码
	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 表示请求的位置是01的子节,是2个子节,而不是 1-0=1 的子节数量。

不断测试下,发现只要满足这几个要求,Safari 就认为接口支持 range 请求:

  1. Content-Length 要正确。
  2. Content-Range 的范围要合理。
  3. 返回了不少于 Range 请求头要求大小的文件流数据。

而下面这几点,不影响 Safari 的校验结果:

  1. Content-Range 的范围和 Range 的指定范围不一样
  2. 读取的数据范围和 Range 的指定范围不一样
  3. 返回任意状态码

总结

  1. IOS 上的 Safari 不支持 video 预加载(preload),浏览器不会自动提取帧画面作为默认的 poster 预览图
  2. Safari 上使用 video 播放视频,必须支持并正确处理 Range 范围请求,浏览器会先发送 bytes=0-1 范围的请求来测试服务器是否支持 Range 请求,如果校验成功,就会继续发送正常范围的 Range 请求。否则不再请求资源。
相关推荐
Jiaberrr4 分钟前
Vue 3 中搭建菜单权限配置界面的详细指南
前端·javascript·vue.js·elementui
懒大王95279 分钟前
uniapp+Vue3 组件之间的传值方法
前端·javascript·uni-app
烛阴1 小时前
秒懂 JSON:JavaScript JSON 方法详解,让你轻松驾驭数据交互!
前端·javascript
拉不动的猪1 小时前
刷刷题31(vue实际项目问题)
前端·javascript·面试
zeijiershuai1 小时前
Ajax-入门、axios请求方式、async、await、Vue生命周期
前端·javascript·ajax
恋猫de小郭1 小时前
Flutter 小技巧之通过 MediaQuery 优化 App 性能
android·前端·flutter
只会写Bug的程序员1 小时前
面试之《webpack从输入到输出经历了什么》
前端·面试·webpack
拉不动的猪1 小时前
刷刷题30(vue3常规面试题)
前端·javascript·面试
狂炫一碗大米饭2 小时前
面试小题:写一个函数实现将输入的数组按指定类型过滤
前端·javascript·面试
最胖的小仙女2 小时前
通过动态获取后端数据判断输入的值打小
开发语言·前端·javascript