iOS 视频自动全屏问题解决方案 --- 技术分享
一、问题背景
在移动端 H5 开发中,视频播放是常见需求。然而 iOS Safari 对 <video> 元素有一项硬性限制:
调用
video.play()时,iOS 会自动将视频切换到系统级全屏播放器,用户无法在页面内继续浏览其他内容。
这在轮播视频、嵌入式视频播放器等场景下体验极差------用户点击播放,整个页面被系统播放器接管,播放完毕才能回到页面。
影响范围
- iOS Safari 所有版本均存在此行为
- iOS 10+ 虽然引入了
playsinline属性,但部分机型和 WebView 环境下仍会强制全屏 - 微信内置浏览器、WKWebView 等场景同样受影响
二、常规方案的局限性
方案一:playsinline 属性
html
<video playsinline webkit-playsinline></video>
- iOS 10+ 支持,理论上可禁止自动全屏
- 局限性:部分低端机型、特定 WebView 下仍会全屏,稳定性无法保证
- 仅解决全屏问题,无法精细控制播放器 UI
方案二:iphone-inline-video 库
js
import makeVideoPlayableInline from 'iphone-inline-video'
makeVideoPlayableInline(videoElement)
- 通过 hack 方式绕过 WebKit 全屏强制逻辑
- 局限性:hack 方式存在伪事件副作用,需要额外处理
三、最终方案:Canvas 帧绘制代理
3.1 核心思路
偷梁换柱------用户看到的不是 <video>,而是 <canvas>。
┌─────────────────────────────────────┐
│ .video-canvas-container │
│ ├── <video> opacity:0, z-index:0 │ ← 数据源(不可见)
│ └── <canvas> z-index:1 │ ← 可见渲染层
└─────────────────────────────────────┘
<video>元素设为opacity: 0,隐藏在后台,仅作为帧数据源<canvas>元素叠在上方,通过ctx.drawImage(video, ...)逐帧绘制视频画面- 用户看到和交互的都是 canvas,canvas 不是媒体元素,iOS 不会触发全屏
3.2 技术架构
┌─────────────────────────────────────────────────────────────┐
│ VideoSwiper.vue │
│ (轮播调度层:方案选择、轮播控制、状态管理) │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ van-swipe (Vant 轮播容器) │ │
│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │
│ │ │ Item 0 │ │ Item 1 │ │ Item 2 │ ... │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ [方案A] │ │ [方案A] │ │ [方案A] │ │ │
│ │ │VideoToCanvas│VideoToCanvas│VideoToCanvas│ │ │
│ │ │ (独立组件) │ │ (独立组件) │ │ (独立组件) │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ [方案B] │ │ [方案B] │ │ [方案B] │ │ │
│ │ │ xgplayer │ │ xgplayer │ │ xgplayer │ │ │
│ │ └───────────┘ └───────────┘ └───────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ VideoToCanvas.vue(独立组件) │
│ (Canvas 播放层:video+canvas、绘制循环、伪事件防御) │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ .video-canvas-container │ │
│ │ ├── <video> (opacity:0, z-index:0) ← 数据源 │ │
│ │ └── <canvas> (z-index:1) ← 可见渲染层 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ 暴露方法: play / pause / destroy / init / resize / │
│ isPaused / isEnded / getCurrentTime / setCurrentTime│
│ 上报事件: play / pause / ended / error / loadedmetadata / │
│ canplay / ready │
└─────────────────────────────────────────────────────────────┘
3.3 方案选择逻辑
通过 useCanvasFallback 计算属性自动判断走哪条路径:
js
const useCanvasFallback = computed(() => {
if (!isIOS()) return false
const version = getIOSMajorVersion()
return version >= 10
})
| 方案 | 条件 | 渲染组件 | 可见元素 |
|---|---|---|---|
| 方案A: iOS Canvas | iOS 10+ | <VideoToCanvas> |
Canvas(video 透明) |
| 方案B: xgplayer | 非 iOS | xgplayer 实例 | xgplayer 内部 video |
四、Canvas 帧绘制实现
4.1 绘制流程
js
startCanvasDraw()
├── 1. stopCanvasDraw() // 先停止旧循环
├── 2. 获取 video/canvas/ctx
├── 3. updateCanvasSize() // canvas 尺寸适配容器 × devicePixelRatio
└── 4. drawFrame() 递归循环
├── 终止条件: video.ended && video.paused → return
├── 跳过条件: video.readyState < 2 → rAF 继续(等帧数据)
├── 绘制:
│ ctx.clearRect → fillRect(黑底) → drawImage(video, contain居中)
└── requestAnimationFrame(drawFrame) → animationFrameId
4.2 核心:drawImage 逐帧绘制
整个方案的核心就是浏览器原生 API drawImage:
js
function drawFrame() {
if (video.ended && video.paused) return
if (video.readyState < 2) {
// 帧数据未就绪,等待下一帧
animationFrameId = requestAnimationFrame(drawFrame)
return
}
ctx.clearRect(0, 0, canvasW, canvasH)
ctx.fillStyle = '#000'
ctx.fillRect(0, 0, canvasW, canvasH)
// contain 居中绘制
const scale = Math.min(canvasW / videoW, canvasH / videoH)
const drawX = (canvasW - videoW * scale) / 2
const drawY = (canvasH - videoH * scale) / 2
ctx.drawImage(video, drawX, drawY, videoW * scale, videoH * scale)
animationFrameId = requestAnimationFrame(drawFrame)
}
4.3 高 DPI 适配
移动端屏幕像素密度高,需适配 devicePixelRatio 避免 canvas 模糊:
js
canvas.width = containerWidth * devicePixelRatio
canvas.height = containerHeight * devicePixelRatio
canvas.style.width = containerWidth + 'px'
canvas.style.height = containerHeight + 'px'
ctx.scale(devicePixelRatio, devicePixelRatio)
五、关键难点:伪事件防御
5.1 问题
iphone-inline-video 库通过 hack 实现 video 内联播放,但会触发伪事件链:
video.play() → 伪 play → 伪 pause → 伪 ended
此时 video.paused 始终为 true,但 Canvas 绘制循环仍在正常工作(视频实际上在播放)。如果不过滤这些伪事件,播放状态管理会完全混乱。
5.2 防御机制
引入 intentionalPlayState 标记主动播放意图,配合双层条件过滤:
intentionalPlayState
{ timestamp }
│
▼
┌──────────── isIntentionalPlay() ────────────────────┐
│ 1. 无状态 → false │
│ 2. timestamp 超时(>500ms) → 清除状态, return false │
│ 3. timestamp 在窗口内 → return true │
└─────────────────────────────────────────────────────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
onPlay onPause onEnded
│ │ │
paused=true isIntentionalPlay isIntentionalPlay
+ isIntentional + currentTime<1 + currentTime<1
│ │ │
→ 接受play → 忽略伪pause → 忽略伪ended
(清除状态) → 正常处理 → 正常处理
5.3 代码实现
js
// 主动播放时记录时间戳
const intentionalPlayState = { timestamp: 0 }
function play() {
intentionalPlayState.timestamp = Date.now()
video.play()
}
function isIntentionalPlay() {
if (!intentionalPlayState.timestamp) return false
if (Date.now() - intentionalPlayState.timestamp > 500) {
intentionalPlayState.timestamp = 0
return false
}
return true
}
// onPause / onEnded 中过滤伪事件
function onPause() {
if (isIntentionalPlay() && video.currentTime < 1) {
// 主动播放窗口内 + 视频刚开始 → 伪事件,忽略
return
}
// 正常暂停逻辑
emit('pause')
}
为什么需要 currentTime < 1 这一层?
500ms 时间窗口可能不够精准,但如果视频 currentTime < 1(刚播放不到 1 秒),就不可能正常结束,一定是伪事件。视频正常播完时 currentTime ≈ duration,不会被误判。
六、为什么这个方案能解决问题?
| 层面 | 原因 |
|---|---|
| iOS 检测目标 | iOS 全屏逻辑只针对 <video> DOM 元素触发,<canvas> 不是媒体元素,不触发全屏 |
| video 不可见 | opacity: 0 + z-index: 0,用户看不到 video,也就感知不到全屏切换 |
| canvas 可见 | 用户交互的都是 canvas,canvas 没有"全屏"概念,就是一个普通 DOM 元素 |
| 数据流通 | drawImage(video, ...) 是浏览器原生 API,可以直接从 video 读取当前帧像素数据,无需手动解码 |
iphone-inline-video |
确保 video 在后台能正常推进 currentTime,提供持续帧数据 |
一句话总结:video 在后台默默播放提供帧数据,canvas 在前台显示画面,iOS 看不到 video 自然不会触发全屏。
七、方案代价与局限
| 维度 | 说明 |
|---|---|
| CPU 开销 | requestAnimationFrame 持续调用 drawImage,CPU 占用高于原生 video 播放 |
| 无原生控件 | canvas 不支持原生播放控件(进度条、音量等),需要自行实现 |
iphone-inline-video 维护 |
该库通过 hack 实现,iOS 大版本更新可能导致失效 |
| 内存占用 | video + canvas 双层渲染,内存占用略高 |
| 兼容范围 | 方案仅在 iOS 10+ 生效,iOS 9 及以下无法使用 |
八、测试 Demo
以下提供完整的可运行 Demo,复制为 HTML 文件即可在 iOS Safari 中测试验证。
8.1 原生 HTML 版(最简 Demo)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>iOS Canvas 视频内联播放 Demo</title>
<script src="https://unpkg.com/iphone-inline-video@2.2.2/build/iphone-inline-video.bundle.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #f3f3f3; font-family: -apple-system, sans-serif; padding: 16px; }
.video-canvas-container {
position: relative;
width: 100%;
height: 220px;
background: #000;
border-radius: 12px;
overflow: hidden;
}
.video-canvas-container video {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
opacity: 0;
z-index: 0;
object-fit: contain;
}
.video-canvas-container canvas {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 1;
}
.controls {
display: flex;
gap: 12px;
margin-top: 16px;
justify-content: center;
}
.controls button {
padding: 10px 28px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
}
.btn-play { background: #1677ff; color: #fff; }
.btn-pause { background: #999; color: #fff; }
.btn-replay { background: #52c41a; color: #fff; }
.status {
text-align: center;
margin-top: 12px;
font-size: 14px;
color: #666;
}
.notice {
margin-top: 20px;
padding: 12px;
background: #fff;
border-radius: 8px;
font-size: 13px;
color: #999;
line-height: 1.6;
}
</style>
</head>
<body>
<h2 style="text-align:center; margin-bottom:16px;">iOS Canvas 视频内联播放</h2>
<div class="video-canvas-container" id="container">
<video id="video" playsinline webkit-playsinline preload="auto"
src="https://www.w3schools.com/html/mov_bbb.mp4"></video>
<canvas id="canvas"></canvas>
</div>
<div class="controls">
<button class="btn-play" onclick="playVideo()">播放</button>
<button class="btn-pause" onclick="pauseVideo()">暂停</button>
<button class="btn-replay" onclick="replayVideo()">重播</button>
</div>
<div class="status" id="status">状态:未播放</div>
<div class="notice">
💡 请在 iOS Safari 中打开此页面测试。点击播放后,视频将在页面内播放,不会自动进入全屏。
</div>
<script>
const video = document.getElementById('video')
const canvas = document.getElementById('canvas')
const ctx = canvas.getContext('2d')
const container = document.getElementById('container')
const statusEl = document.getElementById('status')
let animationFrameId = null
let intentionalPlayTimestamp = 0
// ========== 初始化 iphone-inline-video ==========
if (typeof makeVideoPlayableInline === 'function') {
makeVideoPlayableInline(video)
}
// ========== Canvas 尺寸适配 ==========
function updateCanvasSize() {
const dpr = window.devicePixelRatio || 1
const rect = container.getBoundingClientRect()
canvas.width = rect.width * dpr
canvas.height = rect.height * dpr
canvas.style.width = rect.width + 'px'
canvas.style.height = rect.height + 'px'
ctx.scale(dpr, dpr)
return { width: rect.width, height: rect.height }
}
// ========== 绘制循环 ==========
function drawFrame() {
if (video.ended && video.paused) {
setStatus('播放结束')
return
}
if (video.readyState < 2) {
animationFrameId = requestAnimationFrame(drawFrame)
return
}
const dpr = window.devicePixelRatio || 1
const cw = canvas.width / dpr
const ch = canvas.height / dpr
ctx.clearRect(0, 0, cw, ch)
ctx.fillStyle = '#000'
ctx.fillRect(0, 0, cw, ch)
// contain 居中绘制
const vw = video.videoWidth || 1
const vh = video.videoHeight || 1
const scale = Math.min(cw / vw, ch / vh)
const dx = (cw - vw * scale) / 2
const dy = (ch - vh * scale) / 2
ctx.drawImage(video, dx, dy, vw * scale, vh * scale)
animationFrameId = requestAnimationFrame(drawFrame)
}
function stopDraw() {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
}
// ========== 伪事件防御 ==========
function isIntentionalPlay() {
if (!intentionalPlayTimestamp) return false
if (Date.now() - intentionalPlayTimestamp > 500) {
intentionalPlayTimestamp = 0
return false
}
return true
}
video.addEventListener('play', () => {
if (video.paused && isIntentionalPlay()) {
// 主动播放后的伪 play,接受并启动绘制
intentionalPlayTimestamp = 0
}
startDraw()
setStatus('播放中')
})
video.addEventListener('pause', () => {
if (isIntentionalPlay() && video.currentTime < 1) {
// 伪 pause,忽略
return
}
stopDraw()
setStatus('已暂停')
})
video.addEventListener('ended', () => {
if (isIntentionalPlay() && video.currentTime < 1) {
// 伪 ended,忽略
return
}
stopDraw()
setStatus('播放结束')
})
video.addEventListener('error', () => {
setStatus('视频加载失败')
})
// ========== 播放控制 ==========
function startDraw() {
stopDraw()
updateCanvasSize()
drawFrame()
}
function playVideo() {
intentionalPlayTimestamp = Date.now()
video.play()
}
function pauseVideo() {
video.pause()
}
function replayVideo() {
video.currentTime = 0
intentionalPlayTimestamp = Date.now()
video.play()
}
function setStatus(text) {
statusEl.textContent = '状态:' + text
}
// 页面可见性处理
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
pauseVideo()
}
})
</script>
</body>
</html>
8.2 Vue 3 组件版(VideoToCanvas)
vue
<template>
<div class="video-canvas-container" ref="containerRef">
<video
ref="videoRef"
playsinline
webkit-playsinline
preload="auto"
:src="src"
:poster="poster"
@play="onPlay"
@pause="onPause"
@ended="onEnded"
@error="onError"
@loadedmetadata="onLoadedMetadata"
@canplay="onCanplay"
/>
<canvas ref="canvasRef" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
// 安装: npm install iphone-inline-video
import makeVideoPlayableInline from 'iphone-inline-video'
const props = defineProps<{
src: string
poster?: string
}>()
const emit = defineEmits<{
play: []
pause: []
ended: []
error: [payload: { code: number; message: string; url: string }]
loadedmetadata: [payload: { videoWidth: number; videoHeight: number; duration: number }]
canplay: []
ready: []
}>()
const containerRef = ref<HTMLDivElement>()
const videoRef = ref<HTMLVideoElement>()
const canvasRef = ref<HTMLCanvasElement>()
let ctx: CanvasRenderingContext2D | null = null
let animationFrameId: number | null = null
let intentionalPlayTimestamp = 0
let isDestroyed = false
// ========== 伪事件防御 ==========
function isIntentionalPlay(): boolean {
if (!intentionalPlayTimestamp) return false
if (Date.now() - intentionalPlayTimestamp > 500) {
intentionalPlayTimestamp = 0
return false
}
return true
}
// ========== Canvas 尺寸适配 ==========
function updateCanvasSize() {
if (!containerRef.value || !canvasRef.value || !ctx) return
const dpr = window.devicePixelRatio || 1
const rect = containerRef.value.getBoundingClientRect()
canvasRef.value.width = rect.width * dpr
canvasRef.value.height = rect.height * dpr
canvasRef.value.style.width = rect.width + 'px'
canvasRef.value.style.height = rect.height + 'px'
ctx.setTransform(1, 0, 0, 1, 0, 0) // 重置 scale
ctx.scale(dpr, dpr)
}
// ========== 绘制循环 ==========
function startCanvasDraw() {
stopCanvasDraw()
if (!videoRef.value || !canvasRef.value || !ctx) return
updateCanvasSize()
drawFrame()
}
function drawFrame() {
const video = videoRef.value!
const canvas = canvasRef.value!
if (video.ended && video.paused) {
stopCanvasDraw()
return
}
if (video.readyState < 2) {
animationFrameId = requestAnimationFrame(drawFrame)
return
}
const dpr = window.devicePixelRatio || 1
const cw = canvas.width / dpr
const ch = canvas.height / dpr
ctx!.clearRect(0, 0, cw, ch)
ctx!.fillStyle = '#000'
ctx!.fillRect(0, 0, cw, ch)
const vw = video.videoWidth || 1
const vh = video.videoHeight || 1
const scale = Math.min(cw / vw, ch / vh)
const dx = (cw - vw * scale) / 2
const dy = (ch - vh * scale) / 2
ctx!.drawImage(video, dx, dy, vw * scale, vh * scale)
animationFrameId = requestAnimationFrame(drawFrame)
}
function stopCanvasDraw() {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
}
// ========== video 事件处理 ==========
function onPlay() {
const video = videoRef.value!
if (video.paused && isIntentionalPlay()) {
intentionalPlayTimestamp = 0
}
startCanvasDraw()
emit('play')
}
function onPause() {
if (isIntentionalPlay() && videoRef.value!.currentTime < 1) return
stopCanvasDraw()
emit('pause')
}
function onEnded() {
if (isIntentionalPlay() && videoRef.value!.currentTime < 1) return
stopCanvasDraw()
emit('ended')
}
function onError() {
const video = videoRef.value!
const error = video.error
emit('error', {
code: error?.code || 0,
message: error?.message || 'Unknown error',
url: props.src,
})
}
function onLoadedMetadata() {
const video = videoRef.value!
emit('loadedmetadata', {
videoWidth: video.videoWidth,
videoHeight: video.videoHeight,
duration: video.duration,
})
}
function onCanplay() {
emit('canplay')
}
// ========== 暴露方法 ==========
function play() {
intentionalPlayTimestamp = Date.now()
videoRef.value?.play()
}
function pause() {
if (videoRef.value && !videoRef.value.ended) {
videoRef.value.pause()
}
stopCanvasDraw()
}
function destroy() {
isDestroyed = true
stopCanvasDraw()
if (videoRef.value) {
videoRef.value.src = ''
videoRef.value.load()
}
}
function init() {
if (!videoRef.value || !props.src) return
// 初始化 iphone-inline-video
makeVideoPlayableInline(videoRef.value)
// 初始化 canvas context
ctx = canvasRef.value!.getContext('2d')
emit('ready')
}
function isPaused(): boolean {
return videoRef.value?.paused ?? true
}
function isEnded(): boolean {
return videoRef.value?.ended ?? false
}
function getCurrentTime(): number {
return videoRef.value?.currentTime ?? 0
}
function setCurrentTime(time: number) {
if (videoRef.value) {
videoRef.value.currentTime = time
}
}
function resize() {
stopCanvasDraw()
startCanvasDraw()
}
defineExpose({
play,
pause,
destroy,
init,
resize,
isPaused,
isEnded,
getCurrentTime,
setCurrentTime,
})
// ========== 生命周期 ==========
onMounted(() => {
nextTick(() => init())
})
onBeforeUnmount(() => {
destroy()
})
// 监听 src 变化
watch(
() => props.src,
() => {
if (props.src) {
nextTick(() => init())
}
}
)
</script>
<style scoped>
.video-canvas-container {
position: relative;
width: 100%;
height: 220px;
background: #000;
border-radius: 12px;
overflow: hidden;
}
.video-canvas-container video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
z-index: 0;
object-fit: contain;
}
.video-canvas-container canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
</style>
8.3 在父组件中使用
vue
<template>
<VideoToCanvas
:src="videoUrl"
:poster="posterUrl"
@play="onPlay"
@pause="onPause"
@ended="onEnded"
@error="onError"
@ready="onReady"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import VideoToCanvas from './VideoToCanvas.vue'
const videoUrl = ref('https://www.w3schools.com/html/mov_bbb.mp4')
const posterUrl = ref('https://www.w3schools.com/html/mov_bbb.jpg')
function onPlay() { console.log('播放') }
function onPause() { console.log('暂停') }
function onEnded() { console.log('播放结束') }
function onError(e: any) { console.error('错误:', e) }
function onReady() { console.log('就绪') }
</script>
8.4 依赖安装
bash
# 安装 iphone-inline-video
npm install iphone-inline-video
注意 :纯 HTML Demo 无需安装,通过 CDN 引入即可。Vue 组件版需要安装
iphone-inline-video依赖。
九、总结
iOS 视频自动全屏问题
│
├── 根因: WebKit 对 <video> 元素强制全屏
│
├── 解法: 偷梁换柱
│ ├── video 隐藏做数据源(opacity:0)
│ ├── canvas 前台做展示(drawImage 逐帧绘制)
│ └── iphone-inline-video 确保 video 能内联播放
│
├── 难点: 伪事件防御
│ ├── 500ms 时间窗口标记主动播放意图
│ └── currentTime < 1 双层过滤伪 pause/ended
│
└── 代价: CPU 开销增加、无原生控件、hack 维护风险
该方案本质是利用 Canvas 作为"代理渲染层",绕过了 iOS 对 video 元素的全屏限制。虽然有一定的性能代价和兼容风险,但在 iOS 视频内联播放这一刚需场景下,是目前最可靠的解决方案。