开发一个简易的电台 PWA 应用
涛叔练习英语听力需要听大量的音频。我尝试下载离线资源保存到手机上听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');
.addEventListener('click', (event) => {
list// ...
;
})
let template = document.getElementById('radio-template');
for (let r of radios) {
let clone = template.content.cloneNode(true);
// ...
.appendChild(clone);
list
}; })
因为电台信息是动态生成的,所以理论上可以为对应的节点注册事件处理函数。但我感觉这样有点浪费。另一种简单的方案是在所有电台信息的父节点注册事件处理函数,利用冒泡特性在该函数里监听并处理各子元素(电台信息)的事件。这种方式好像叫事件委托。所以在上面的代码中,我只在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);
.querySelector('img').src = r.cover;
clone.querySelector('.radio-name').innerText = r.name;
clone.querySelector('.radio-desc').innerText = r.desc;
clone
let action = clone.querySelector('.action.play')
.dataset.name = r.name;
action.dataset.desc = r.desc;
action.dataset.src = r.src;
action.dataset.cover = r.cover; action
获取克隆节点对象之后,我们可以直接选取该对象的子节点并更新。比如像封面、名字和简介等可以直接修改对应的属性字段。对于控制按钮,我这里是把所有信息都保存到 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')) {
.querySelectorAll('.action.pause').forEach((i) => {
list.pause();
player.classList.replace('pause', 'play');
i;
})
let data = event.target.dataset;
.src = data.src;
player.play();
player.replace('play', 'pause');
clss
navigator.mediaSession.metadata = new MediaMetadata({
title: data.name + " - " + data.desc,
artwork: [{ src: data.cover }],
;
})else {
} .pause();
player.src = "";
player.replace('pause', 'play');
clss }
以上就是 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 监听系统事件,实现电台切换功能。这些内容就留给有兴趣的读者吧。也欢迎大家留言讨论。