Chrome 扩展开发笔记
今天研究了一下 Chrome 扩展的基本结构,尝试着开发了一款音乐播放器 (Kotori Music Player)。随便记点东西。
先看看这个拓展的效果,如下图:
基本结构
Chrome 扩展都包含一个 Manifest 文件 ——manifest.json,这个文件可以告诉 Chrome 关于这个扩展的相关信息,它是整个扩展的入口,也是 Chrome 扩展必不可少的部分。 Chrome 扩展的 Manifest 文件必须包含 name、version 和 manifest_version 属性,目前来说 manifest_version 属性值只能为数字 2,对于应用来说,还必须包含 app 属性。
其他常用的可选属性还有 browser_action、page_action、background、permissions、options_page、content_scripts,所以我们可以保留一份 manifest.json 模板,当编写新的扩展时直接填入相应的属性值。如果我们需要的属性不在这个模板中,可以再去查阅官方文档,但我想这样的一份模板可以应对大部分的扩展了。下面就放上 Kotori Music Player 的 manifest.json 文件模板:
{
"icons": { //定义了一些图标文件,放在images文件夹下,每个图标的名称和像素需要对应。
"16": "images/icon-16.png",
"32": "images/icon-32.png",
"48": "images/icon-48.png",
"128": "images/icon-128.png"
},
"browser_action": { // browser_action指定扩展的图标放在Chrome的工具栏中
"default_popup": "popup.html",
"default_icon": { // 定义了在工具栏中显示的图标
"19": "images/icon-19.png",
"38": "images/icon-38.png"
}
},
"manifest_version": 2,//manifest的版本,这里现在填2就好了
"name": "Kotori Music Player",//扩展的名字
"version": "1.4",扩展的当前版本,这里由你自己控制,以后升级的时候,要比当前版本高就好了。
"description": "调用虾米API播放选定的精选集,支持歌词同步显示。",// 对于这个拓展的描述
"background": {
"page": "background.html",
"persistent": true
},
"options_page": "options.html",
"permissions": [
"tabs",
"storage",
"notifications",
"http://*/*",
"https://*/*"
]
}
以上除了 permissions 都能根据字面意思理解,permissions 属性中声明需要跨域的权限。一些 API 比如 tabs,notifications,
contextMenus 等都需要在 permissions 中声明。
接下来看一下 background.html,主要就是放置一个 audio 标签,用于后台播放歌曲文件。
<html>
<head>
<meta charset="utf-8"/>
</head>
<body>
<audio id="music" src=""></audio>
<script type="text/javascript" src="js/jquery.min.js"></script>
<script type="text/javascript" src="js/global.js"></script>
<script type="text/javascript" src="js/background.js"></script>
</body>
</html>
其中主要的 JavaScript 脚本 background.js 如下,主要包括精选集解析,歌曲解析,LRC 格式歌词解析。在 background 处理完必要的事件后,将一些需要和前台 popup 交互的元素复制到变量里:
//各种需要用到的变量
var audio = $('#music').get(0);
var item = null;
var isPlaying = false;
var currentMusic = 0;
var prevMusic = -1;
var repeat = parseInt(localStorage.repeat);
var ratio = 0;
var name = '';
var artist = '';
var cover = '';
var playlist = null;
var lyricUrl = null;
var lyric = null;
var collectID = getCollectID();
$.ajax({
url: 'http://kotori.sinaapp.com/xiami/collect/' + collectID,
type: 'get',
dataType: 'json',
async: false,
success: function(data) {
playlist = data;
},
error: function() {
showNotification({
type: "basic",
title: 'Message',
message: 'Kotori API Error',
iconUrl: 'images/icon-128.png'
});
}
});
console.log('<<<Kotoriの电台>>>');
console.log('Version : 20150719');
console.log('Current Music: ' + currentMusic + ' Repeat: ' + repeat);
var loadMusic = function(i) {
currentMusic = localStorage.currentMusic = i;
//console.log(localStorage.currentMusic);
item = playlist[i];
$.ajax({
type: "get",
async: false,
dataType: "json",
url: 'https://kotori.sinaapp.com/xiami/single/' + item.id,
success: function(data) {
if (data.url != null) {
src = data.url;
audio.setAttribute("src", src);
cover = data.img;
artist = data.artist_name;
name = data.name;
lyricUrl = data.lyric;
if (isPlaying) {
play();
showNotification({
type: "basic",
title: name,
message: artist,
iconUrl: cover
});
}
console.log('Song Title: ' + data.name + ' Song Artist: ' + data.artist_name);
} else {
showNotification({
type: "basic",
title: 'Message',
message: '歌曲获取失败,请重试。',
iconUrl: 'images/icon-128.png'
});
}
},
});
}
var changeMusic = function(i) {
loadMusic(i);
}
var randomNum = function(min, max) {
return Math.floor(min + Math.random() * (max - min));
}
var autoChange = function() {
var nextMusic = 0;
switch (repeat) {
case 0:
prevMusic = currentMusic;
nextMusic = randomNum(0, playlist.length);
changeMusic(nextMusic);
break;
case 1:
audio.currentTime = 0.0;
play();
break;
case 2:
if (currentMusic == playlist.length - 1) {
changeMusic(0);
} else {
changeMusic(currentMusic + 1);
}
break;
}
}
var updateProgress = function() {
if (audio.currentTime == audio.duration) {
//autoChange();
next();
}
ratio = audio.currentTime / audio.duration * 100;
}
var getLyric = function() {
if (lyricUrl == null) {
return;
}
$.ajax({
url: lyricUrl,
type: 'get',
dataType: 'text',
async: false,
success: function(data) {
lyric = parseLyric(data);
},
error: function() {
showNotification({
type: "basic",
title: 'Message',
message: 'Lyric Fetch Error',
iconUrl: 'images/icon-128.png'
});
}
});
}
var parseLyric = function(text) {
//get each line from the text
var lines = text.split('\n'),
//this regex mathes the time [00.12.78]
pattern = /\[\d{2}:\d{2}.\d{2}\]/g,
result = [];
// Get offset from lyrics
var offset = getOffset(text);
//exclude the description parts or empty parts of the lyric
while (!pattern.test(lines[0])) {
lines = lines.slice(1);
};
//remove the last empty item
lines[lines.length - 1].length === 0 && lines.pop();
//display all content on the page
lines.forEach(function(v, i, a) {
var time = v.match(pattern),
value = v.replace(pattern, '');
time.forEach(function(v1, i1, a1) {
//convert the [min:sec] to secs format then store into result
var t = v1.slice(1, -1).split(':');
result.push([parseInt(t[0], 10) * 60 + parseFloat(t[1]) + parseInt(offset) / 1000, value]);
});
});
//sort the result by time
result.sort(function(a, b) {
return a[0] - b[0];
});
return result;
}
var getOffset = function(text) {
//Returns offset in miliseconds.
var offset = 0;
try {
// Pattern matches [offset:1000]
var offsetPattern = /\[offset:\-?\+?\d+\]/g,
// Get only the first match.
offset_line = text.match(offsetPattern)[0],
// Get the second part of the offset.
offset_str = offset_line.split(':')[1];
// Convert it to Int.
offset = parseInt(offset_str);
} catch (err) {
//alert("offset error: "+err.message);
offset = 0;
}
return offset;
}
var play = function() {
audio.play();
getLyric();
timeout = setInterval(updateProgress, 500);
//setInterval(showLyric, 1000);
isPlaying = true;
}
var pause = function() {
audio.pause();
clearInterval(timeout);
isPlaying = false;
}
var next = function() {
if (repeat == 0) {
prevMusic = currentMusic;
nextMusic = randomNum(0, playlist.length);
changeMusic(nextMusic);
} else if (currentMusic == playlist.length - 1) {
changeMusic(0);
} else {
changeMusic(currentMusic + 1);
}
}
var previous = function() {
if (repeat == 0 && prevMusic != -1) {
changeMusic(prevMusic);
prevMusic = randomNum(0, playlist.length);
} else if (currentMusic == 0) {
changeMusic(playlist.length - 1);
} else {
changeMusic(currentMusic - 1);
}
}
//初始化操作
loadMusic(currentMusic);
关于 LRC 歌词文件,一般格式如下:
[ar:文筱芮]
[by:airplay]
[00:00.00]那个
[00:03.00]作词:文筱芮 作曲:文筱芮
[00:06.00]编曲:于韵非
[00:09.00]制作人:胡海泉 秦天
[00:12.00]演唱:文筱芮
这样挺有规律的,用正则可以很方便地将时间与歌词提取分离。
但凡事得多个心眼啊。事后发生的事情证明这句话有多正确。我在整理歌词时还发现了另外一种形式,像下面这样:
[ar:庭竹]
[al:爱的九宫格]
[by:airplay]
[00:00.17]庭竹 - 公主的天堂
[00:05.40]作曲:陈嘉唯、Skot Suyama 陶山、庭竹
[00:07.33]作词:庭竹
[00:15.59]风铃的音谱 在耳边打转
[00:18.62]城堡里 公主也摆脱了黑暗的囚禁
[00:22.82]她一点点地 无声悄悄地慢慢长大
[00:26.36]期待着 深锁木门后的世界
[01:38.72][00:29.76]
[01:51.48][00:30.32]树上 小鸟的轻响 在身边打转
[01:55.35][00:34.09]公主已 忘记木制衣橱背后的惆怅
[01:59.65][00:38.35]她跳舞唱歌天真无邪地寻找属于自己的光亮和快乐
[02:06.98][00:45.76]
[02:07.41][00:46.06]树叶一层层拨开了伪装
[02:11.29][00:50.25]彩虹一步步露出美丽脸庞 无限的光亮
这种形式的歌词把歌词内容相同但时间不同的部分合并,节省了篇幅。
所以,现在知道的歌词其实有两种写法了,不过都还算规律,用正则可以搞定,只是对于第二种,处理时得将时间再次分割。
JavaScript 解析法上面已经给出,本扩展最开始采用的是 PHP 解析成 JSON 格式,但是试过效率并不高,代码如下:
private function parseLyric($lyric)
{
if ($lyric) {
// 远程获取歌词内容
$content = @file_get_contents($lyric);
// 按”回车换行“将歌词切割成数组
$array = explode("\n", $content);
$lrc = array();
foreach ($array as $val) {
// 清除掉”回车不换行“符号
$val = preg_replace('/\r/', '', $val);
// 正则匹配歌词时间
$temp = preg_match_all('/\[\d{2}\:\d{2}\.\d{2}\]/', $val, $matches);
if (!empty($matches[0])) {
$data_plus = "";
$time_array = array();
// 将可能匹配的多个时间挑选出来,例如:[00:21]、[03:40]
foreach ($matches[0] as $V) {
$data_plus .= $V;
$V = str_replace("[", "", $V);
$V = str_replace("]", "", $V);
$date_array = explode(":", $V);
// 将例如:00:21、03:40 转换成秒
$time_array[] = intval(intval($date_array[0] * 60) + intval($date_array[1]));
}
// 将上面的得到的时间,例如:[00:21][03:40],替换成空,得到歌词
$data_plus = str_replace($data_plus, "", $val);
// 将时间和歌词组合到数组中
foreach ($time_array as $V) {
//$lrc[] = array($V, $data_plus);
$lrc['time' . $V] = $data_plus;
}
}
}
// 按时间顺序来排序数组
//$lrc = $this->bsort($lrc);
// 输出 json格式
return json_encode($lrc);
}
return 'no lyric';
}
private function bsort(array $array)
{
$count = count($array);
for ($i = 0; $i < $count; $i++) {
for ($j = $count - 1; $j > $i; $j--) {
if ($array[$j][0] < $array[$j - 1][0]) {
$temp = $array[$j];
$array[$j] = $array[$j - 1];
$array[$j - 1] = $temp;
}
}
}
return $array;
}
下面看一下 popup.html 文件的内容,该页面主要就是播放器和播放列表。
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" type="text/css" href="css/popup.css">
</head>
<body>
<div id="popup">
<div id="nav_buttons" role="button">
<div class="breadcrumb-part"></div>
<span class="tab-text">正在播放</span>
<input type="text" placeholder="搜索播放列表..." id="search" style="display:none;" />
</div>
<div id="loadingOverlay" style="display: none;" class="regularLoadingOverlay"></div>
<div id="close_nav" style="display:none;" title="Click to close navigation">X</div>
<div id="navigate" style="display:none;">
</div>
<div id="player">
<div id="album_art" class="big_art">
<img id="album_art_img" src="../images/default_album_med.png" height="128" width="128" />
</div>
<div id="song_indicator"></div>
<div id="song_info">
<div id="song_title"></div>
<div id="artist"></div>
<div id="lyric"></div>
</div>
<div id="time_and_slider">
<div class="player-middle">
<div id="slider" class="goog-slider-horizontal goog-slider-track" role="slider" tabindex="0">
<div class="playing-progress-background">
<div id="played_slider"></div>
</div>
<div class="goog-slider-thumb" style="top: 21px; left: 15px; display:none;"></div>
</div>
</div>
<div id="time">
<span id="current_time">0:00</span> / <span id="total_time">0:00</span>
</div>
</div>
<div id="controls">
<div id="rew" class="goog-flat-button goog-inline-block" title="Previous song" style="-webkit-user-select: none; "></div>
<div id="playPause" class="goog-flat-button goog-inline-block" title="Play" style="-webkit-user-select: none; "></div>
<div id="ff" class="goog-flat-button goog-inline-block" title="Next song" role="button" style="-webkit-user-select: none; "></div>
</div>
</div>
</div>
<script src="js/jquery.min.js"></script>
<script src="js/popup.js"></script>
</body>
</html>
这里的 js 文件主要就是控制 background 页面中的 audio 标签,就不给出篇幅了。
关键一句就是 var bg = chrome.extension.getBackgroundPage();
通过 bg.xx 就可以调用 background 中的变量和方法。
歌词同步的具体思路:
- 首先将 LRC 文件读取为文本
- 用 String.prototype.split (’\n’); 将整个文本以换行符为单位分隔成一行一行的文本,保存到一个数组中
- 然后将开头部分不属于歌词的文本去掉,得到只有时间与歌词的干净文件
- 对于每一行,匹配出时间与文字,分别存入数组 [time,text],然后将每行得到的这样的数组存入一个大的数组 [[time,text],[time,text]…]
- 利用 Audio 标签的 ontimeupdate 事件,不断比较当然播放时间 audio.currentTime 与数组中每个元素中时间,如果当前时间大于某个歌词中的时间,则显示该歌词
到这里本文就基本结束了,以后再补充好了 233333
项目地址
安装地址
参考文档
入门:建立 Chrome 扩展程序
chrome.tabs
如何从零开始写一个 Chrome 扩展?
官方入门文档
Chrome 扩展开发笔记
论 HTML5 Audio 标签歌词同步的实现