第七章:移动端兼容性

如果说桌面端的视频开发是在一块平整的地基上盖楼,那移动端就是在布满断层的地形上施工。iOS Safari 的 autoplay 管控、Android 碎片化的事件行为、微信 X5 内核的全屏劫持,以及页面切换时各端截然不同的续播逻辑——每一个都是独立的战场。这一章把这些战场逐一拆开,给出系统的应对方案。


第一部分:iOS Safari 的 autoplay 管控体系

策略演进:从 iOS 10 开始的松绑

iOS Safari 对 autoplay 的管控有一段清晰的演进历史。在 iOS 9 及之前,所有媒体播放都强制要求用户手势触发,没有任何例外。这个策略的初衷是保护移动用户的流量和电量,但它也让 GIF 替代方案(用 <video> 替代 GIF 以节省带宽)完全无法在 iOS 上工作。

iOS 10 开始,WebKit 官方博客宣布了新的策略,放开了两个关键口子:无音轨的视频可以不需要用户手势直接 autoplay,静音(muted)视频也可以直接 autoplay。webkit.org 这两条规则构成了今天 iOS 视频 autoplay 的核心逻辑,后续版本基本延续这个框架,只在细节上有所调整。

当前(iOS 16+)的完整规则如下:

  • muted 视频 + autoplay + playsinline可以 autoplay,无需用户手势
  • 有声音视频 + autoplay不可以 autoplay,必须等待用户手势
  • 通过用户手势调用 video.play()可以播放有声音的视频
  • 非用户手势上下文(如 setTimeoutfetch.then 回调)调用 video.play()不可以,会被拒绝

"用户手势"的定义非常严格——必须是 touchendclickdoubleclickkeydown 事件处理函数中直接调用的 play()。如果中间经过了一个 await、一个 setTimeout,或者一个异步 fetch,用户手势上下文就断掉了,play() 会返回一个被 reject 的 Promise。

// ✅ 正确:在用户手势的同步上下文中调用
button.addEventListener('click', () => {
  video.play(); // 手势上下文完整,可以播放
});
 
// ❌ 错误:await 断掉了手势上下文
button.addEventListener('click', async () => {
  await fetch('/api/video-url'); // 手势上下文在这里断掉
  video.play(); // NotAllowedError
});
 
// ✅ 正确的异步播放处理:先播再换源
button.addEventListener('click', async () => {
  video.muted = true;
  video.play(); // 先在手势上下文中触发播放(静音)
  
  const { url } = await fetch('/api/video-url').then(r => r.json());
  video.src = url;
  video.load();
  video.play(); // 此时 play() 已经被"解锁",可以播放
});

playsinlinewebkit-playsinline 的关系

playsinline 是 HTML 规范中的标准属性,作用是告诉浏览器在元素所在的位置内联播放,而不是进入全屏模式。在 iOS 10 之前,WebKit 使用私有前缀版本 webkit-playsinline,iOS 10 开始 WebKit 正式将其去前缀,采用标准的 playsinline

在实践中,为了兼容 iOS 9 及更早版本(以及一些未更新的 WebView 壳),两个属性应该同时写

<video
  autoplay
  muted
  loop
  playsinline
  webkit-playsinline
>
  <source src="video.mp4" type="video/mp4" />
</video>

需要特别注意的是:playsinline 只影响 iPhone。在 iPad 上,视频默认就是内联播放的,不需要这个属性。另外,playsinline 只是允许内联播放,并不自动授予 autoplay 权限——autoplay 权限仍然受 muted 规则约束。


WKWebView 的额外限制

如果你的 H5 页面运行在 iOS App 的 WKWebView 中(而不是 Safari),情况会更复杂。WKWebView 的默认配置比 Safari 更严格——即使视频有 muted 属性,也可能无法 autoplay,并且视频可能会强制进入全屏的原生播放器。

这需要 App 侧在初始化 WKWebView 时修改配置:

// Swift - WKWebView 配置
let config = WKWebViewConfiguration()
 
// 允许内联播放
config.allowsInlineMediaPlayback = true
 
// 不要求用户手势才能播放(允许静音视频 autoplay)
config.mediaTypesRequiringUserActionForPlayback = []
 
let webView = WKWebView(frame: .zero, configuration: config)

如果你是纯 H5 开发者,遇到在某个 App 的 WebView 里视频无法 autoplay,大概率是 App 侧没有做上述配置。这种情况无法从 H5 侧绕过,需要推动 App 侧修改。


iOS 上的 user-select: none 防暂停

这是一个高频踩坑点:在 iOS Safari 中,用户触摸 &lt;video&gt; 元素会触发暂停,即使视频没有 controls 属性也如此。解决方案是通过 CSS 禁用用户选择:

video {
  user-select: none;
  -webkit-user-select: none;
}

如果视频是作为背景或装饰性元素(不需要用户交互),还可以加上 pointer-events: none 彻底屏蔽触摸事件。


第二部分:Android 的碎片化兼容

Android 的核心问题:系统劫持

Android 的视频兼容性问题比 iOS 复杂得多,因为 Android 生态高度碎片化——不同厂商(华为、小米、OPPO、vivo)会在系统层面对视频播放做不同程度的干预,有些系统会直接用自己的原生播放器接管 &lt;video&gt; 元素,导致 H5 侧的控制完全失效。

在原生 Android Chrome 上,视频行为与桌面 Chrome 基本一致,autoplay 策略遵循 Media Engagement Index(MEI)——用户经常访问的网站会有更高的 MEI 分数,允许 autoplay;新网站则需要 muted 才能 autoplay。

Android 上最重要的一个行为差异是:修改 currentTime 会触发 canplay 事件,但 iOS 不会。这意味着不能用 canplay 来判断 Seek 完成,应该统一使用 seeked 事件。


Android 事件行为差异对照

事件/行为Android ChromeiOS Safari说明
currentTime 修改后触发 canplay✅ 会触发❌ 不会触发Seek 完成应监听 seeked
preload="auto" 实际生效部分机型生效通常被忽略移动端不应依赖 preload
后台切换后自动暂停大部分机型需要 visibilitychange 处理
后台切换回来后自动续播部分机型(如 X5)自动续播不自动续播需要手动 play()
play() 返回 Promise✅ 支持✅ 支持(iOS 10+)必须处理 rejection
全屏时触发 resize✅ 会触发❌ 不一定触发全屏布局调整的依据

跨端安全的 play() 调用模板

async function safePlay(video) {
  try {
    await video.play();
  } catch (err) {
    if (err.name === 'NotAllowedError') {
      // 没有用户手势,降级处理:显示播放按钮
      showPlayButton(video);
    } else if (err.name === 'NotSupportedError') {
      // 格式不支持,尝试降级格式
      console.error('视频格式不支持:', video.currentSrc);
    } else if (err.name === 'AbortError') {
      // 播放被中断(通常是 src 被改变),可以忽略
    } else {
      console.error('播放失败:', err.name, err.message);
    }
  }
}
 
function showPlayButton(video) {
  const btn = document.createElement('button');
  btn.textContent = '点击播放';
  btn.style.cssText = `
    position: absolute;
    top: 50%; left: 50%;
    transform: translate(-50%, -50%);
    padding: 12px 24px;
    background: rgba(0,0,0,0.7);
    color: #fff;
    border: none;
    border-radius: 4px;
    font-size: 16px;
    cursor: pointer;
    z-index: 100;
  `;
  btn.addEventListener('click', () => {
    video.play();
    btn.remove();
  }, { once: true });
  video.parentElement.style.position = 'relative';
  video.parentElement.appendChild(btn);
}

第三部分:微信 X5 内核的同层播放

X5 内核的视频劫持问题

X5 是腾讯基于 Webkit 开发的浏览器内核,应用于 Android 端的微信、QQ、QQ 浏览器等。X5 内核的 &lt;video&gt; 元素有一个长期存在的历史问题:视频播放时会被提升到页面最顶层,成为一个独立的 Native View,任何 HTML 元素都无法覆盖在它上面。这意味着你精心设计的自定义控制栏、弹幕层、广告蒙层,在 X5 上全部会被视频遮挡。

X5 提供了"同层播放器"来解决这个问题,核心是通过私有属性告诉 X5 内核以特殊方式渲染视频,使其可以被 HTML 元素覆盖。 mrluo.life


三种播放形态的区别

X5 视频播放有三种形态,通过不同的属性组合控制:

形态一:局域播放(视频在文档内播放,可被 HTML 元素覆盖)

<video
  x5-playsinline="true"
  playsinline
  webkit-playsinline
>
  <source src="video.mp4" />
</video>

局域播放是最接近标准 Web 行为的形态,视频在 &lt;video&gt; 元素所在位置播放,可以在视频上方叠加 HTML 内容。但注意:x5-playsinline 只对 Android X5 内核有效,iOS 微信使用的是 Chrome 内核,直接用标准 playsinline 即可。

形态二:同层全屏播放(视频全屏,但 HTML 元素可以覆盖)

<video
  x5-video-player-type="h5-page"
  playsinline
>
  <source src="video.mp4" />
</video>

x5-video-player-type="h5-page" 是同层播放的核心属性。设置后,视频会进入全屏状态,但这个全屏是"同层"的——HTML 元素可以通过绝对定位覆盖在视频上方,实现自定义控制栏、弹幕等效果。这是目前在 X5 内核上实现自定义视频 UI 的主流方案。

形态三:Native 全屏播放(默认行为,HTML 无法覆盖)

不设置任何 X5 属性时,X5 会在用户点击播放后进入 Native 全屏模式,整个视频播放器是系统级的,H5 无法干预。


同层播放的完整实现

同层播放涉及的核心事件是 x5videoenterfullscreenx5videoexitfullscreen,需要在这两个事件中同步布局状态:

class X5VideoPlayer {
  constructor(videoEl, containerEl) {
    this.video = videoEl;
    this.container = containerEl;
    this.isFullscreen = false;
 
    this._bindEvents();
  }
 
  _bindEvents() {
    // 进入 X5 同层全屏
    this.video.addEventListener('x5videoenterfullscreen', () => {
      this.isFullscreen = true;
      document.body.classList.add('x5-fullscreen');
 
      // 同层播放进入全屏会触发 window resize
      // 需要在 resize 中设置 video 尺寸为屏幕尺寸
    });
 
    // 退出 X5 同层全屏
    this.video.addEventListener('x5videoexitfullscreen', () => {
      this.isFullscreen = false;
      document.body.classList.remove('x5-fullscreen', 'x5-landscape');
 
      // 重置 video 尺寸(必须清除,否则视频会撑开页面)
      this.video.style.width = '';
      this.video.style.height = '';
    });
 
    // 同层全屏会触发 resize,但退出全屏不会
    window.addEventListener('resize', () => {
      if (this.isFullscreen) {
        // 将 video 设为屏幕尺寸,使其真正充满全屏
        this.video.style.width = window.screen.width + 'px';
        this.video.style.height = window.screen.height + 'px';
      }
    });
  }
 
  // 切换横屏全屏(自定义全屏按钮的实现)
  enterLandscape() {
    if (!this.isFullscreen) return;
    this.video.setAttribute('x5-video-orientation', 'landscape');
    // 注意:不要在这里立即修改布局,等 orientationchange 事件
  }
 
  exitLandscape() {
    this.video.setAttribute('x5-video-orientation', 'portrait');
  }
}
 
// 横屏布局调整
window.addEventListener('orientationchange', () => {
  // orientationchange 触发时,布局尚未完成,需要稍作延迟
  setTimeout(() => {
    if (window.orientation === 90 || window.orientation === -90) {
      document.body.classList.add('x5-landscape');
    } else {
      document.body.classList.remove('x5-landscape');
    }
  }, 100);
});

对应的 CSS 结构:

/* 默认状态 */
.video-container {
  position: relative;
  width: 100%;
  height: 200px;
}
 
/* 自定义控制栏:覆盖在视频上方 */
.custom-controls {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 9999;
  background: linear-gradient(transparent, rgba(0,0,0,0.7));
  padding: 8px 12px;
}
 
/* X5 同层全屏状态 */
body.x5-fullscreen .video-container {
  position: fixed;
  top: 0; left: 0;
  width: 100vw;
  height: 100vh;
  z-index: 9998;
}
 
/* X5 横屏全屏状态 */
body.x5-landscape .video-container {
  width: 100vh;
  height: 100vw;
}
 
/* 同层全屏下,控制栏需要相对于容器定位 */
body.x5-fullscreen .custom-controls {
  position: absolute;
  bottom: 0;
}
 
/* 隐藏 X5 原生控制栏(需要不设置 controls 属性配合) */
video::-webkit-media-controls {
  display: none !important;
}

X5 的其他关键限制

Android 微信(X5 内核)不支持同时播放一个 &lt;video&gt; 和一个 &lt;audio&gt;。如果你的页面需要背景音乐 + 视频同时播放,必须把背景音乐改用 Web Audio API 实现——Web Audio API 与 &lt;video&gt; 可以在 X5 上共存。

另外,X5 内核的 &lt;video&gt; 属性必须在播放前设置完毕,播放开始后再设置 x5-video-player-type 等属性是无效的。这意味着这些属性要写在 HTML 里,或者在调用 play() 之前通过 JS setAttribute 设置。


第四部分:页面切换与续播处理

visibilitychange 的跨端差异

visibilitychange 是处理页面切换(切到后台、锁屏、切换 Tab)的标准 API,但它在不同端的表现存在重要差异。

在 iOS 上,当用户切换到其他 App 或锁屏时,document.visibilityState 会变为 hidden,视频会自动暂停。当用户返回时,visibilityState 变为 visible,但视频不会自动续播,需要手动调用 play()

在 Android 上,行为更加复杂。部分机型(如 X5 内核)在切后台时会自动暂停,返回时会自动续播,不需要手动干预。但另一些机型(如 Android 原生 Chrome)行为与 iOS 类似,需要手动续播。还有一个特殊情况:Android 微信中,如果触发了音频权限弹窗,即使用户还在当前页面,也会触发 visibilitychangehidden,但 iOS 不会有这个行为。juejin.cn


完整的页面切换续播方案

正确的处理策略是:在 hidden 时记录当前播放状态(是否正在播放、当前时间),在 visible 时根据记录的状态决定是否恢复播放:

class VideoVisibilityManager {
  constructor(video) {
    this.video = video;
    this.wasPlaying = false;
    this.savedTime = 0;
 
    this._bindVisibilityChange();
    this._bindPageLifecycle();
  }
 
  _bindVisibilityChange() {
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        this._onHidden();
      } else if (document.visibilityState === 'visible') {
        this._onVisible();
      }
    });
  }
 
  // iOS Safari 的 pagehide/pageshow(比 visibilitychange 更可靠)
  _bindPageLifecycle() {
    window.addEventListener('pagehide', () => this._onHidden());
    window.addEventListener('pageshow', (e) => {
      // e.persisted === true 表示页面从 bfcache(往返缓存)恢复
      // 此时视频状态可能已经被冻结,需要重新初始化
      if (e.persisted) {
        this._onBFCacheRestore();
      }
    });
  }
 
  _onHidden() {
    // 记录播放状态
    this.wasPlaying = !this.video.paused && !this.video.ended;
    this.savedTime = this.video.currentTime;
 
    // 主动暂停(部分机型不会自动暂停)
    if (this.wasPlaying) {
      this.video.pause();
    }
  }
 
  _onVisible() {
    // 恢复播放时间(防止部分机型返回后时间重置)
    if (Math.abs(this.video.currentTime - this.savedTime) > 1) {
      this.video.currentTime = this.savedTime;
    }
 
    // 仅在之前正在播放时才恢复
    if (this.wasPlaying) {
      safePlay(this.video).catch(() => {
        // 如果 autoplay 被拒绝(用户切换后策略改变),显示播放按钮
        showPlayButton(this.video);
      });
    }
  }
 
  _onBFCacheRestore() {
    // bfcache 恢复后,视频可能处于冻结状态
    // 需要重新 load() 才能恢复正常
    const currentTime = this.video.currentTime;
    this.video.load();
    this.video.currentTime = currentTime;
 
    if (this.wasPlaying) {
      safePlay(this.video);
    }
  }
}

微信内置浏览器的特殊事件

微信提供了一个私有事件 WeixinJSBridgeReady,在微信内置浏览器中,这个事件触发后才能调用 WeixinJSBridge.invoke('getNetworkType', ...) 等微信 API,同时这也是解锁自动播放的时机:

// 检测是否在微信内置浏览器中
function isWechat() {
  return /MicroMessenger/i.test(navigator.userAgent);
}
 
// 微信内置浏览器的 autoplay 解锁
if (isWechat()) {
  document.addEventListener('WeixinJSBridgeReady', () => {
    // WeixinJSBridgeReady 触发后,视频 play() 被视为用户手势上下文
    video.play().catch(() => {});
  }, false);
}

第五部分:全屏控制与屏幕方向

Fullscreen API 在移动端的差异

标准的 Fullscreen API(element.requestFullscreen())在移动端的支持情况参差不齐:

// 跨浏览器兼容的全屏切换
function requestFullscreen(element) {
  const fn =
    element.requestFullscreen ||
    element.webkitRequestFullscreen ||   // Safari / 旧版 Chrome
    element.mozRequestFullScreen ||      // Firefox
    element.msRequestFullscreen;         // IE/Edge
 
  if (fn) {
    fn.call(element).catch(err => {
      // iOS Safari 不支持 requestFullscreen,会在这里 reject
      console.warn('全屏请求失败:', err.message);
    });
  }
}
 
function exitFullscreen() {
  const fn =
    document.exitFullscreen ||
    document.webkitExitFullscreen ||
    document.mozCancelFullScreen ||
    document.msExitFullscreen;
 
  if (fn) fn.call(document);
}
 
// 监听全屏状态变化
document.addEventListener('fullscreenchange', onFullscreenChange);
document.addEventListener('webkitfullscreenchange', onFullscreenChange);
 
function onFullscreenChange() {
  const isFullscreen = !!(
    document.fullscreenElement ||
    document.webkitFullscreenElement
  );
  updateFullscreenUI(isFullscreen);
}

iOS Safari 完全不支持 requestFullscreen(),这是一个已知的长期限制。在 iOS 上,进入全屏的唯一方式是通过原生视频播放器(点击视频上的全屏按钮),或者用 CSS 模拟全屏(将视频定位到充满视口)。


屏幕方向控制

在 Android 上,可以通过 screen.orientation.lock() 锁定屏幕方向(需要页面处于全屏状态):

async function lockLandscape() {
  try {
    // 必须先进入全屏
    await document.documentElement.requestFullscreen();
    // 再锁定横屏
    await screen.orientation.lock('landscape');
  } catch (err) {
    // iOS 不支持 screen.orientation.lock,会在这里 reject
    // 降级方案:用 CSS transform 模拟横屏
    simulateLandscape();
  }
}
 
// iOS 上的降级方案:CSS transform 模拟横屏
function simulateLandscape() {
  const vh = window.innerHeight;
  const vw = window.innerWidth;
 
  // 将页面旋转 90 度并调整尺寸,视觉上实现横屏效果
  document.body.style.cssText = `
    transform: rotate(90deg);
    transform-origin: left top;
    width: ${vh}px;
    height: ${vw}px;
    position: fixed;
    top: 0;
    left: ${vw}px;
    overflow: hidden;
  `;
}

第六部分:微信小程序 &lt;video&gt; 组件

与 Web &lt;video&gt; 的核心差异

微信小程序的 &lt;video&gt; 组件和 Web 的 &lt;video&gt; 元素看起来相似,但底层完全不同——小程序的 &lt;video&gt; 是原生组件,不是 HTML 元素,通过 WXML 声明式语法使用,通过 VideoContext API 控制。

最关键的差异在于控制方式:

<!-- WXML -->
<video
  id="myVideo"
  src="{{ videoSrc }}"
  autoplay="{{ false }}"
  controls="{{ true }}"
  muted="{{ false }}"
  show-progress="{{ true }}"
  show-center-play-btn="{{ true }}"
  picture-in-picture-mode="push"
  bindplay="onPlay"
  bindpause="onPause"
  bindended="onEnded"
  bindtimeupdate="onTimeUpdate"
  bindfullscreenchange="onFullscreenChange"
/>
// JS 控制层
Page({
  onReady() {
    // 通过 createVideoContext 获取控制实例,而不是 document.getElementById
    this.videoCtx = wx.createVideoContext('myVideo', this);
  },
 
  onPlay() {
    console.log('视频开始播放');
  },
 
  // 播放控制
  play() {
    this.videoCtx.play();
  },
  pause() {
    this.videoCtx.pause();
  },
  seek(position) {
    this.videoCtx.seek(position); // 单位:秒
  },
  requestFullScreen() {
    this.videoCtx.requestFullScreen({ direction: 90 }); // 90: 横屏, 0: 竖屏
  },
  exitFullScreen() {
    this.videoCtx.exitFullScreen();
  }
});

小程序 &lt;video&gt; 的独有属性(续)

小程序 &lt;video&gt; 提供了一些 Web &lt;video&gt; 没有的能力,其中最重要的是原生 DRM 支持和画中画:

<!-- DRM 加密视频(Web 端需要 EME,小程序原生支持) -->
<video
  id="drmVideo"
  src="https://example.com/encrypted.mp4"
  is-drm="{{true}}"
  provision-url="https://drm.example.com/provision"
  certificate-url="https://drm.example.com/cert"
  license-url="https://drm.example.com/license"
/>
 
<!-- 画中画模式(push: 页面跳转时触发, pop: 返回时触发) -->
<video
  id="pipVideo"
  src="{{videoSrc}}"
  picture-in-picture-mode="{{['push', 'pop']}}"
  bindenterpictureinpicture="onEnterPIP"
  bindleavepictureinpicture="onLeavePIP"
/>

小程序 &lt;video&gt; 还有一套 Web 端没有对应物的 UI 控制属性,可以精细控制播放器原生 UI 的显示:

<video
  src="{{videoSrc}}"
  controls="{{true}}"
  show-progress="{{true}}"
  show-play-btn="{{true}}"
  show-center-play-btn="{{false}}"
  show-fullscreen-btn="{{true}}"
  show-bottom-progress="{{true}}"
  show-mute-btn="{{true}}"
  show-casting-button="{{false}}"
  enable-progress-gesture="{{true}}"
/>

小程序与 Web 的关键差异对照

维度Web &lt;video&gt;小程序 &lt;video&gt;
声明方式HTML 元素WXML 原生组件
JS 控制document.getElementById()wx.createVideoContext()
事件系统DOM 事件(addEventListener组件事件(bind*
全屏控制requestFullscreen() APIvideoCtx.requestFullScreen()
DRM 支持EME API(需要自行对接)原生属性(is-drm 等)
画中画Picture-in-Picture APIpicture-in-picture-mode 属性
MSE 支持✅ 支持❌ 不支持
Canvas 绘制ctx.drawImage(video, ...)videoCtx.sendFrame()
层级遮挡正常文档流原生组件,默认最高层级

最后一条"层级遮挡"是小程序开发中的经典坑:小程序的 &lt;video&gt; 是原生组件,和 Web 上早期 X5 内核的问题一样,默认处于所有 WXML 组件的上方,无法被普通 &lt;view&gt; 遮挡。如果需要在视频上方叠加自定义 UI,必须使用 &lt;cover-view&gt;&lt;cover-image&gt; 这两个专门为覆盖原生组件设计的特殊组件。

<!-- 正确:用 cover-view 覆盖在 video 上方 -->
<video id="myVideo" src="{{src}}">
  <cover-view class="custom-controls">
    <cover-view class="play-btn" bindtap="togglePlay">
      {{isPlaying ? '暂停' : '播放'}}
    </cover-view>
    <cover-view class="progress-bar">
      <!-- 进度条 UI -->
    </cover-view>
  </cover-view>
</video>
 
<!-- ❌ 错误:普通 view 会被 video 遮挡 -->
<video id="myVideo" src="{{src}}" />
<view class="custom-controls">...</view>  <!-- 被遮挡,不可见 -->

第七部分:移动端视频的完整兼容模板

将前面所有章节的内容整合,给出一个可以直接用于生产的移动端视频兼容模板:

<!DOCTYPE html>
<html>
<head>
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <style>
    .video-wrapper {
      position: relative;
      width: 100%;
      background: #000;
    }
    .video-wrapper video {
      width: 100%;
      display: block;
      user-select: none;
      -webkit-user-select: none;
    }
    /* 自定义播放按钮(autoplay 被拒绝时显示) */
    .play-overlay {
      display: none;
      position: absolute;
      top: 50%; left: 50%;
      transform: translate(-50%, -50%);
      width: 60px; height: 60px;
      background: rgba(0,0,0,0.6);
      border-radius: 50%;
      cursor: pointer;
      align-items: center;
      justify-content: center;
    }
    .play-overlay.visible {
      display: flex;
    }
    .play-overlay::after {
      content: '';
      border-style: solid;
      border-width: 10px 0 10px 18px;
      border-color: transparent transparent transparent #fff;
      margin-left: 4px;
    }
  </style>
</head>
<body>
 
<div class="video-wrapper" id="videoWrapper">
  <video
    id="myVideo"
 
    autoplay
    muted
    loop
    preload="metadata"
 
    playsinline
    webkit-playsinline
 
    x5-playsinline="true"
    x5-video-player-type="h5-page"
    x5-video-player-fullscreen="true"
 
    poster="cover.jpg"
  >
    <source src="video.mp4" type="video/mp4" />
    <p>您的浏览器不支持 HTML5 视频</p>
  </video>
 
  <div class="play-overlay" id="playOverlay"></div>
</div>
 
<script>
(function() {
  const video   = document.getElementById('myVideo');
  const overlay = document.getElementById('playOverlay');
 
  // ─── 1. 安全播放 ──────────────────────────────────────────
  async function safePlay() {
    try {
      await video.play();
      overlay.classList.remove('visible');
    } catch (err) {
      if (err.name === 'NotAllowedError') {
        // autoplay 被拒绝,显示播放按钮
        overlay.classList.add('visible');
      } else if (err.name === 'NotSupportedError') {
        console.error('视频格式不支持');
      }
      // AbortError 通常是 src 切换导致的,可以忽略
    }
  }
 
  // ─── 2. 用户手势解锁 ──────────────────────────────────────
  overlay.addEventListener('click', () => {
    video.muted = false; // 尝试开启声音
    safePlay();
  });
 
  // ─── 3. 微信 WeixinJSBridgeReady 解锁 ─────────────────────
  if (/MicroMessenger/i.test(navigator.userAgent)) {
    if (typeof WeixinJSBridge !== 'undefined') {
      safePlay();
    } else {
      document.addEventListener('WeixinJSBridgeReady', safePlay, false);
    }
  } else {
    safePlay();
  }
 
  // ─── 4. visibilitychange 续播 ─────────────────────────────
  let wasPlaying = false;
  let savedTime  = 0;
 
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') {
      wasPlaying = !video.paused && !video.ended;
      savedTime  = video.currentTime;
      if (wasPlaying) video.pause();
 
    } else if (document.visibilityState === 'visible') {
      // 防止部分机型返回后时间漂移
      if (Math.abs(video.currentTime - savedTime) > 1) {
        video.currentTime = savedTime;
      }
      if (wasPlaying) safePlay();
    }
  });
 
  // ─── 5. bfcache 恢复处理(iOS Safari)─────────────────────
  window.addEventListener('pageshow', (e) => {
    if (e.persisted) {
      const t = video.currentTime;
      video.load();
      video.currentTime = t;
      if (wasPlaying) safePlay();
    }
  });
 
  // ─── 6. X5 同层全屏事件 ───────────────────────────────────
  let x5Fullscreen = false;
 
  video.addEventListener('x5videoenterfullscreen', () => {
    x5Fullscreen = true;
    document.body.classList.add('x5-fullscreen');
  });
 
  video.addEventListener('x5videoexitfullscreen', () => {
    x5Fullscreen = false;
    document.body.classList.remove('x5-fullscreen');
    video.style.width = video.style.height = '';
  });
 
  window.addEventListener('resize', () => {
    if (x5Fullscreen) {
      video.style.width  = window.screen.width  + 'px';
      video.style.height = window.screen.height + 'px';
    }
  });
 
  // ─── 7. 错误处理 ──────────────────────────────────────────
  video.addEventListener('error', () => {
    const err = video.error;
    const msg = {
      1: '用户中止加载',
      2: '网络错误',
      3: '解码错误',
      4: '格式不支持'
    }[err?.code] || '未知错误';
    console.error('视频错误:', msg);
  });
 
})();
</script>
 
</body>
</html>

第八部分:环境检测工具函数

在实际项目中,往往需要先检测运行环境,再决定采用哪套兼容方案。以下是一套常用的环境检测工具函数:

const Env = {
  // UA 检测
  ua: navigator.userAgent,
 
  get isIOS()     { return /iPhone|iPad|iPod/i.test(this.ua); },
  get isAndroid() { return /Android/i.test(this.ua); },
  get isWechat()  { return /MicroMessenger/i.test(this.ua); },
  get isQQ()      { return /QQ\//i.test(this.ua); },
 
  // iOS 版本号
  get iOSVersion() {
    const m = this.ua.match(/OS (\d+)_(\d+)/);
    return m ? parseFloat(`${m[1]}.${m[2]}`) : null;
  },
 
  // 是否 X5 内核(Android 微信/QQ)
  get isX5() {
    return this.isAndroid && (this.isWechat || this.isQQ);
  },
 
  // 是否支持 autoplay(静音)
  async canAutoplay() {
    const v = document.createElement('video');
    v.muted = true;
    v.src = 'data:video/mp4;base64,AAAAIGZ0eXBpc29t'; // 最小 MP4
    try {
      await v.play();
      v.pause();
      return true;
    } catch {
      return false;
    }
  },
 
  // 是否支持内联播放
  get supportsPlaysinline() {
    return 'playsInline' in document.createElement('video');
  },
 
  // 是否支持 MSE
  get supportsMSE() {
    return typeof MediaSource !== 'undefined';
  },
 
  // 打印当前环境信息(调试用)
  print() {
    console.table({
      iOS:             this.isIOS,
      Android:         this.isAndroid,
      WeChat:          this.isWechat,
      X5Kernel:        this.isX5,
      iOSVersion:      this.iOSVersion,
      playsinline:     this.supportsPlaysinline,
      MSE:             this.supportsMSE,
    });
  }
};
 
// 使用示例
Env.print();
 
// 根据环境选择方案
if (Env.isX5) {
  // 使用 X5 同层播放方案
  video.setAttribute('x5-video-player-type', 'h5-page');
} else if (Env.isIOS) {
  // 使用 iOS playsinline 方案
  video.setAttribute('playsinline', '');
  video.setAttribute('webkit-playsinline', '');
}

至此,第七章《移动端兼容性》完整收尾。本章覆盖了从 iOS autoplay 策略演进、WKWebView 额外限制、Android 碎片化事件差异,到微信 X5 同层播放的三种形态、visibilitychange 跨端续播、小程序 &lt;video&gt; 与 Web 的本质差异,最后以一个可直接用于生产的完整兼容模板和环境检测工具函数收尾。

下一章(第八章)将进入性能优化专题,涵盖懒加载、预加载策略、码率自适应、内存管理与渲染性能等核心议题,需要的话随时告诉我。