开发一个简易的电台 PWA 应用

2023-03-14 ⏳8.3分钟(3.3千字)

练习英语听力需要听大量的音频。我尝试下载离线资源保存到手机上听1。但更新或者扩展内容都不太方便。于是便改听在线广播。很多在线广播就是一个 CDN 链接,对应特定格式的音频文件(MP3、AAC等)。它们完全可以通过 HTML 的<audio>播放。所以我就想做一个 PWA 应用,把合适的广播资源都集成起来方便使用。一番折腾之后,做了一个很简易的版本。今天把开发过程整理出来,分享给大家。今天分享的内容完全基于浏览器标准 API 开发,没有使用 JavaScript 框架2,便于初学者入门。前端大佬请轻喷。

每个 PWA 应用都需要 manifest.json 文件,用来指定应用信息。我之前以为一个域名只能有一个 PWA 应用。其实不然。同一域名(站点)下的不同的路径都可以指定自己的 manifest.json 文件。也就说一个域名下可以发布无限多的 PWA 应用。甚至同一个 PWA 应用中的不同的页面下可以指定 manifest.json,然后就可以把该页面单独添加到主屏幕。

我的应用取名为 Online Radio,路径为 https://taoshu.in/radio/,它的 manifest.json 内容为:

{
  "$schema": "https://json.schemastore.org/web-manifest-combined.json",
  "id": "https://taoshu.in/radio/",
  "name": "Radio Online",
  "short_name": "Radio Online",
  "start_url": ".",
  "display": "fullscreen",
  "description": "Online Radios for English Learners",
  "icons": [
    {
      "src": "./icon.png",
      "sizes": "512x512"
    }
  ]
}

第一个$schema是固定的,写死。

第二个id是当前应用的唯一标识。当系统需要打开或者唤醒 PWA 应用的时候就依赖这个参数。比如系统收到 Web 推送[^web-push]或者用户点击锁屏界面上的媒体播放器等场景。 MDN 文档3说这个字段只要保持唯一就行。但苹果系统要求这个字段需要是合法的 URL。如果不是 URL 而且系统里又添加了多个域名相同的 PWA 应用,那么系统可能会打开其他的应用。

名字、短名就不说了,按需指定。start_url表示应用启动后打开的页面,使用当前页面就可以了。

display控制应用显示样式,这里指定为fullscreen,隐藏浏览器的 UI,让 PWA 看起来更像是原生应用。

描述随便写。icons指定应用图标。强烈建议指定该字段。浏览器安装 PWA 应用时会使用该字段指定的图片作为应用图标。如果不指定,老版本的 Safari 生成页面快照当作图标,非常难看。iOS 13.4 beta 版有改进,会根据应用名字首字母或者汉字自动生成图标,但 BUG很多。另外该图片文件不能使用透明背景,不然 iOS 会自动转化成黑色背景。

有了 manifest.json 配置,我们还要准备 HTML 页面骨架。内容也是非常简单。

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Online Radios for English Learners</title>
    <meta charset="UTF-8">
    <meta name="viewport" content="user-scalable=no,
                                   initial-scale=1,
                                   maximum-scale=1,
                                   minimum-scale=1,
                                   width=device-width,
                                   height=device-height">
    <link rel="icon" href="icon.png">
    <link rel="manifest" href="manifest.json">
    <link rel="stylesheet" href="/water.css">
    <link rel="stylesheet" href="style.css">
    <link rel="apple-touch-icon" href="./icon.png">
    <script src="app.js"></script>
  </head>
  <body>
    <h1>Online Radios</h1>
    <p>for English Learners</p>
    <div id="radio-list"></div>
    <audio id="player" src="" preload="none"></audio>
    <template id="radio-template"></template>
  </body>
</html>

先看<head>部分。这里主要是一些配置信息和外部资源依赖。标题和编码就不说了。 viewport需要注意,这里的一大坨就是为了让页面充满整个屏幕并禁用缩放。浏览器默认会以桌面窗口大小渲染页面,然后再缩小显示到手机屏幕,必须手动放大才能看清。使用 viewport可以禁用这一行为。

<link>部分主要是指定各类资源。比如icon用于指定页面图标,浏览器一般会显示在标签页的左边。这个图标也叫 favicon,最早好像只能在收藏夹展示。之前仅支持 icon 模式,现在像是 PNG、GIF、SVG4 等模式都支持了。

需要注意的是apple-touch-icon,这是苹果支持的私有字段。在 iOS 支持 manifest.json 之前,Safari 从这个字段提取应用图标。如果不考虑支持老版本的 iOS 系统就不用加它。

最后是指定应用的 JS 文件。

<body>部分比较简单。除了标题和说明之外,我用一个<div承载所有的电台信息,这部分通过 JS 动态生成。然后是一个<audio>用来播放音频。理论上也可以通过 JS 动态创建<audio>标签,但感觉没有必要。另外浏览器默认会预加载音频信息,我们不需要,直接禁用。

另一点需要说明的是我用了<template>标签。这是电台信息 DOM 结构的模版。因为没用 JS 框架,我是通过克隆模版节点并填充对应信息来构造电台 DOM 结构的,最后追加到信息列表。下面会详细说明。

虽然是直接操作 DOM 树,但数据和显示分离的原则还是要坚守。所以我在 app.js 中定义了如下列表:

const radios = [
  {
    "name": "BBC World Service",
    "desc": "International news, analysis and information.",
    "src": "https://stream.live.vc.bbcmedia.co.uk/bbc_world_service",
    "cover": "https://taoshu.in/radio/icons/bbc-ws.png"
  },
];

这是需要展示的电台信息,除了最核心的音频链接外,还需要指定名字、简介和封面三个字段。这样做主要是为了排版美观的需要。排版设计对我这样的程序员来说比较难😂通过 radios变量,后续可以很方便地添加或者修改电台信息。

电台信息可以到 radio.net 上找。好像需要🪜才能访问😂

然后就是生成 DOM 节点并监听用户事件。整个逻辑都放到页面加载完成之后进行:

document.addEventListener('DOMContentLoaded', (event) => {
  let list = document.getElementById('radio-list');
  let player =  document.getElementById('player');

  list.addEventListener('click', (event) => {
    // ...
  });

  let template = document.getElementById('radio-template');

  for (let r of radios) {
    let clone = template.content.cloneNode(true);
    // ...
    list.appendChild(clone);
  }
});

因为电台信息是动态生成的,所以理论上可以为对应的节点注册事件处理函数。但我感觉这样有点浪费。另一种简单的方案是在所有电台信息的父节点注册事件处理函数,利用冒泡特性在该函数里监听并处理各子元素(电台信息)的事件。这种方式好像叫事件委托。所以在上面的代码中,我只在list上注册了click回调。具体的处理逻辑下面会讲。

下面的部分是动态生成电台信息。核心是通过template对象的content。cloneNode方法复制一组新节点。参数true表示递归复制子元素。一番处理后追加到list列表中。

template结构如下:

<template id="radio-template">
  <div class="radio-container">
    <div class="cover"><img src=""></div>
    <div class="control-container">
      <div class="action play"></div>
    </div>
    <div class="meta-container">
      <div class="meta radio-name"></div>
      <div class="meta radio-desc"></div>
    </div>
  </div>
</template>

最外面有一个容器。容器内有三部分,分别是封面、按钮和信息三部分。按钮用<div> 实现。信息部分又包含名字和简介两部分。排版我们放后面说。现在继续讲怎样生成电台 DOM 节点。说其来也是非常简单:

let clone = template.content.cloneNode(true);
clone.querySelector('img').src = r.cover;
clone.querySelector('.radio-name').innerText = r.name;
clone.querySelector('.radio-desc').innerText = r.desc;

let action = clone.querySelector('.action.play')
action.dataset.name = r.name;
action.dataset.desc = r.desc;
action.dataset.src = r.src;
action.dataset.cover = r.cover;

获取克隆节点对象之后,我们可以直接选取该对象的子节点并更新。比如像封面、名字和简介等可以直接修改对应的属性字段。对于控制按钮,我这里是把所有信息都保存到 dataset 中,供后续使用。但能不能从 DOM 节点查询呢?也是可以的。这里冗余一份到 dataset 纯粹是为了方便在事件回调中直接使用。

现在说控制回调逻辑。

所有子元素发生 click 事件后都会触发回调,所以要检查它是否为我们关注的事件。播放按钮都有action这个class,只要检查是否有这个class就行了。

let clss = event.target.classList;

if (!clss.contains('action')) {
  return;
}

确定之后开始处理。控制按钮除了action类之外,还可能有play或者pause类。前者表示点击之后开始播放,后者表示点击之后暂停播放。两者互斥。对于播放操作,我们还需要查询当前正在播放的电台,将其状态重置为停止。DOM 接口的 classList 提供有 contains()replace()接口,非常方便。

播放和暂停分别对应<audio>play()pause()接口。因为是流式音频,浏览器在暂停播放之后可能还是会继续下载数据。为避免不必要的流量浪费,我在暂停之后直接清空到src属性。不过这只是我的猜测,欢迎熟悉这方面的不吝赐教。

最后提一下 mediaSession API5。通过该接口可以设置系统的播放界面信息,当然也可以监听系统的播放事件。这里我只演示了如何设置标题和封面信息。

let clss = event.target.classList;

if (clss.contains('play')) {
  list.querySelectorAll('.action.pause').forEach((i) => {
    player.pause();
    i.classList.replace('pause', 'play');
  });

  let data = event.target.dataset;
  player.src = data.src;
  player.play();
  clss.replace('play', 'pause');

  navigator.mediaSession.metadata = new MediaMetadata({
    title: data.name + " - " + data.desc,
    artwork: [{ src: data.cover }],
  });
} else {
  player.pause();
  player.src = "";
  clss.replace('pause', 'play');
}

以上就是 JavaScript 部分的全部内容。大家也可能通过这个链接下载完整代码。

最后说一下排版,也就是 CSS。

Safari 默认会有一个滑动回弹效果。在网页没什么,但在 PWA 应用中回弹总觉得很怪。我们可以通过 CSS 禁用。

html {
  --size: 4em;
  overscroll-behavior: none;
}

很多资料说是要给body设置,但我试下来发现需要给html设置。这里我还定义了--size 变量供后续使用。

然后需要微调 water.css 的默认样式。

body {
  margin: 0 auto;
  padding: 0 2px;
}

这里取消了上下的外边距并保持居中,再左右两边设置两个像素的内边距,不然电台封面会贴到屏幕边缘,不太美观。

接下来处理按钮部分。我们在 JavaScript 中只是修改按钮的 class,并没有改动其显示内容。按钮图标是通过 CSS 来控制的。

.action {
  width: var(--size);
  height: var(--size);
  min-width: var(--size);
  cursor: pointer;
  background-repeat: no-repeat;
  background-size: cover;
}

.action.play {
  background-image: url(./play.svg);
}

.action.pause {
  background-image: url(./pause.svg);
}

每个按钮都指定了宽度和最小宽度。最小宽度我们后面说。用cursor: pointer模拟按钮效果。然后指定背景图片的公共属性,简单来说就是让背景图片居中并充满整个容器。不同的按钮使用单独的 class 控制需要展示的图片。我用的 SVG 图片来自 https://www.svgrepo.com

以上三条规则配合 JS 切换 class 就能控制按钮的展示效果。

我在使用的时候发现首次切换按钮会先变白再切换,这是因为对应的 svg 文件没有加载。可以使用meta指定预加载文件解决这个问题:

<link rel="preload" href="play.svg" as="image">
<link rel="preload" href="pause.svg" as="image">

封面图片的样式也比较简单:

.cover {
  height: var(--size);
  width: var(--size);
  min-width: var(--size);
}

这里同样指定了最小宽度,后面说😄

最后我指定容器样式。这里用了flex模式,让所有子元素从左向右排列并垂直居中。

.radio-container {
  display: flex;
  margin-top: 2px;
}

.meta-container {
  height: var(--size);
}

如果没有给封面元素指定最小宽度,那么在小屏幕下封面会被挤得很小。但很奇怪,按钮区域没有这个问题。有了解的读者请务必赐教。

最后加上点眼之笔:

.meta.radio-desc {
  font-weight: 100;
}

字面意思是让简介部分的字体变细。但为什么要么做呢?我的本质诉求是想着让不同电台之间在视觉上能区分开来。最直接的方案是加边框。我试了一下,太丑了。把描述字重改细,一方面可以让标题跟描述在视觉上区分开来,增加排版的层次感;另一方面就是让封面、按钮、标题、简介,再加上元素上面的外边距,它们综合作用,形成一个整体。虽然没有加边框,但感觉上好像是一个整体,基本实现了我的排版目的。我没学过排版,但对目前的方案还比较满意。这是空白的力量,小即是多😄

以上就是本文的全部内容。我用比较少的代码实现了简易的电台应用,基本满足了我的日常需求。现在每天都在听。但毕竟还是太简单了,有很多地方可以改进。比如电台开始播放后会有一段缓冲时间,这段时间内可以显示加载图标,当前是直接显示暂停图标。比如还可以通过 Media Session API 监听系统事件,实现电台切换功能。这些内容就留给有兴趣的读者吧。也欢迎大家留言讨论。


  1. ./podcast-rss.html↩︎

  2. 为了重置不同浏览的默认样式以及支持夜间模式,我使用了 water.css。↩︎

  3. https://developer.mozilla.org/en-US/docs/Web/Manifest/id↩︎

  4. ../svg-favicons.html↩︎

  5. https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API↩︎