iOS Safari 播放音频的技巧分享

开发 Web App 一直是个蛮尴尬的事情。一方面,Google 不断在推;PWA 等技术也越来越好;另一方面,当我们真的像用 Web 开发 App 的时候,总会在不同地方踩到各种坑,猝不及防。尤其是 iOS Safari,各种 bug 层出不穷,坑死个人。今天分享一下在 iOS Safari 播放音频的一些技巧,希望节省大家将来的时间。

基础:使用 new Audio() 创建播放器

播放声音看起来并不难,在桌面上实现也比较简单,直接 const audio = new Audio(音频地址); audio.play() 就好了。只要你的音频文件可以正常访问,那你应该很快就能听到声音。

这个过程实际上创建了一个 HTMLAudioElement 的实例。所以,你也可以在页面上直接插入一个 <audio>,然后通过某种方式拿到它的引用,再使用它播放音频。

除了初始化的时候传入音频地址,在任何时候改变音频地址,然后再 .play() 都可以,很简单。

在用户点击时提前创建播放器

但是在 iOS Safari 上,就有问题。因为手机端的限制更严格,只有用户主动操作(点击、敲击)时,新建的 <audio> 才能正常播放。如果你在其它时间创建 <audio> 并播放,一切看起来都是正常的,没有任何报错,网络也会正常产生请求,但就是不会发出声音。

解决方案就是在用户产生点击、敲击的时候,就把 <audio> 创建好,然后在需要播放的时候让它播放声音。

双播放器切换实现连续播放

有时候我们需要循环连续播放一首歌,比如游戏背景音乐,或者打坐的时候播一些背景音。这个时候我们也有两个选择:

  1. 使用 <audio>
    • 好处:流式播放,速度会更快;有 loop 属性,更方便。
    • 坏处:如果是长音频,由于流式播放的原因,用过的数据可能被随时丢弃,所以新的一遍开始时可能会卡 0.N 秒,无法避免
  2. 使用 Web Audio API 等更高级的 API
    • 好处:音频在内存里,想怎么播怎么播,还可以加入各种效果
    • 坏处:第一次播需要完成加载,或者手动实现音频流,很麻烦

我建议用第二个方法,使用起来比较简单。只要在开始的时候创建两个 <aduio> 对象,然后在第一个播放结束前 250ms 启动第二个即可。至于为什么是 250ms,因为按照规范,onTimeUpdate 的触发周期是最长不超过 250ms,所以这样可以比较好的切换播放器。

以下是我在 Nuxt3 里使用的 composable,供大家参考:

import type { AudioOptions } from '~/types';

export function useAudio({
  src,
  loop,
  loopOverlap,
  autoplay,
}: AudioOptions = {}) {
  const audio = shallowRef<HTMLAudioElement>();
  const backup = shallowRef<HTMLAudioElement>();
  const isStarting = ref<boolean>(false);
  const isPlaying = ref<boolean>(false);
  const playhead = ref<number>(0);
  const duration = ref<number>(0);

  async function playAudio(src: string, overlap: number = 0.25): Promise<void> {
    if (!audio.value) {
      initAudio(src);
    }
    if (audio.value) {
      audio.value.src = src;
      if (loop && backup.value) {
        backup.value.src = src;
      }
    }
    if (overlap !== undefined) {
      loopOverlap = overlap;
    }
    isStarting.value = true;
    await audio.value?.play();
  }
  function pauseAudio(): void {
    audio.value?.pause();
    backup.value?.pause();
  }
  function initAudio(src: string): void {
    audio.value = new Audio(src);
    if (src) {
      audio.value.preload = 'auto';
    }
    audio.value.autoplay = autoplay || false;
    if (loop) {
      backup.value = new Audio(src);
      backup.value.preload = 'auto';
      backup.value.autoplay = false;
      backup.value.addEventListener('ended', onEnded);
      backup.value.addEventListener('timeupdate', onTimeUpdate);
      backup.value.addEventListener('play', onPlay);
      backup.value.addEventListener('playing', onPlaying);
      backup.value.addEventListener('pause', onPause);
      backup.value.addEventListener('loadedmetadata', onMetadata);
    }
    audio.value.addEventListener('timeupdate', onTimeUpdate);
    audio.value.addEventListener('loadedmetadata', onMetadata);
    audio.value.addEventListener('ended', onEnded);
    audio.value.addEventListener('pause', onPause);
    audio.value.addEventListener('play', onPlay);
    audio.value.addEventListener('playing', onPlaying);
  }
  function destroy(): void {
    if (audio.value) {
      audio.value.removeEventListener('timeupdate', onTimeUpdate);
      audio.value.removeEventListener('loadedmetadata', onMetadata);
      audio.value.removeEventListener('ended', onEnded);
      audio.value.removeEventListener('pause', onPause);
      audio.value.removeEventListener('play', onPlay);
      audio.value.removeEventListener('playing', onPlaying);
      audio.value = undefined;
    }
    if (!backup.value) return;
    backup.value.removeEventListener('ended', onEnded);
    backup.value.removeEventListener('loadedmetadata', onMetadata);
    backup.value.removeEventListener('timeupdate', onTimeUpdate);
    backup.value.removeEventListener('play', onPlay);
    backup.value.removeEventListener('playing', onPlaying);
    backup.value.removeEventListener('pause', onPause);
    backup.value = undefined;
  }

  function onPlay(): void {
    isPlaying.value = true;
  }
  function onPlaying(): void {
    isStarting.value = false;
  }
  function onPause(): void {
   isPlaying.value = false;
 }
  function onEnded(event: Event): void {
    if (loop) {
      (event.target as HTMLAudioElement).currentTime = 0;
    } else {
      isPlaying.value = false;
    }
  }
  function onTimeUpdate(): void {
    playhead.value = audio.value?.currentTime || 0;
    if (!loop) return;
    if (!playhead.value || !duration.value || playhead.value < duration.value - loopOverlap) return;

    backup.value?.play();
    // swap audio and backup
    const temp = audio.value;
    audio.value = backup.value;
    backup.value = temp;
  }
  function onMetadata(event: Event): void {
    duration.value = (event.target as HTMLAudioElement).duration || 0;
  }

  if (src) {
    initAudio(src);
  }

  return {
    audio,
    isStarting,
    isPlaying,
    playhead,

    playAudio,
    pauseAudio,
    destroy,
  };
}

使用 macOS Safari 调试 iOS Safari

Safari 烂归烂,但是有一点:iOS Safari 的 bug 大多可以在 macOS 上复现。所以大多数时候,我们可以直接在 macOS 完成调试。但是有时候,我们必须在 iOS 上进行调试,比如涉及到输入框影响屏幕高度、或者上面提到的播放问题。

于是,使用 macOS Safari 调试 iOS Safari 也是必备技能,需要的同学请看我的这个视频:

总结

我有时候会怀疑,苹果是不是故意劣化 Safari,以避免被 Web App 抢走原生 App 的市场份额,不然 iOS Safari 的 bug 不应该这么多。

如果大家对 Web App 开发感兴趣,有相关的问题,欢迎留言讨论。

如果您觉得文章内容对您有用,不妨支持我创作更多有价值的分享:


已发布

分类

来自

标签:

评论

欢迎吐槽,共同进步

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据