今天研究了一下 Chrome 扩展的基本结构,尝试着开发了一款音乐播放器 (Kotori Music Player)。随便记点东西。

先看看这个拓展的效果,如下图:

EC25D950-E3CB-4BE5-ACF8-EF7C1843BAA4.png

216C02A1-A210-4032-8214-CF0433421D0E.png

基本结构

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

项目地址

GitHub

安装地址

Chrome Web Store

参考文档

入门:建立 Chrome 扩展程序
chrome.tabs
如何从零开始写一个 Chrome 扩展?
官方入门文档
Chrome 扩展开发笔记
论 HTML5 Audio 标签歌词同步的实现