为你的 React 应用添加音乐播放器

January 10, 2023

本文介绍 aplayer-react 库,以及我如何使用 aplayer-react 在我的 Gatsby 博客中播放网易云音乐歌曲。

最近在培养每周在我的博客更新周记的习惯。 在周记的开头,我会分享本周我在网易云音乐上新收藏的歌曲,有时是一首,有时也会有多首。 相比仅仅是将歌曲的链接贴在文中,我想干脆在周记中显示一个音乐播放器,将歌曲播放出来。

播放器的选型上我选择 @DIYgod 创建的 APlayer 库,因为我很喜欢它的外观。由于我的博客使用 React 编写,我编写了 aplayer-react 库,使得可以以 React 组件的形式使用 APlayer。

aplayer-react

aplayer-react 的实现上其实并未调用 APlayer 的 API。事实上,它只是使用了 APlayer 的样式表,功能逻辑则完全使用 React 重写。可以将其理解为“APlayer 原型的 React 实现”。

示例效果如下:

Screenshot for aplayer-react

主要的特性包括:

  • 滚动歌词
  • 音量控制,可以切换静音
  • 播放列表,可切换顺序播放/随机播放,以及单曲循环/列表循环
  • 根据歌曲封面自动适配主题色
  • 支持服务端渲染

基础用例

一个最基本的用例如下,为 APlayer 组件的 audio 属性传入一个包含歌曲信息的对象。

import { APlayer } from "aplayer-react"
import "aplayer/dist/APlayer.min.css"

render(
  <APlayer
    audio={{
      name: "Dancing with my phone",
      artist: "HYBS",
      url: "https://music.163.com/song/media/outer/url?id=1969744125",
      cover:
        "https://p1.music.126.net/tOtUdKjS9rktAFRamcomWQ==/109951167748733958.jpg",
    }}
  />
)

aplayer-react 截图

滚动歌词

audio 对象的 lrc 字段设置 LRC 格式的歌词,即可在界面上显示跟随歌曲进度滚动的歌词。

render(
  <APlayer
    audio={{
      name: "Dancing with my phone",
      artist: "HYBS",
      url: "https://music.163.com/song/media/outer/url?id=1969744125",
      cover:
        "https://p1.music.126.net/tOtUdKjS9rktAFRamcomWQ==/109951167748733958.jpg",
      lrc: "[00:00.000] 作词 : James Alyn Wee/Kasidej Hongladaromp\n[00:01.000] 作曲 : James Alyn Wee/Kasidej Hongladaromp\n[00:28.836] I'm just laying on the floor again\n[00:33.124] Can't be bothered to get up now\n[00:36.345] I wouldn’t care\n[00:38.348] If I never get up again\n[00:41.363] I don’t want to\n[00:47.388] Then our song comes on the radio\n[00:51.906] Makes me wanna start to dance, oh\n[00:55.163] I wanna know\n[00:56.997] If you feel the same way as me\n[01:00.097] Why would you go?\n[01:02.695]\n[01:05.780] Dancing, I'm all alone\n[01:09.163] Figuring out how I can get you home\n[01:15.129] Dancing with my phone\n[01:18.393] Thinking about you\n[01:22.292]\n[01:25.154] On my feet and now I'm out the door\n[01:29.741] Walking by the places that we used to go\n[01:34.478] I remember all your favorite stores\n[01:37.743] I won't lie\n[01:43.345] I don't think I even know myself anymore\n[01:52.741] You're the one who knew me ****ing well\n[01:58.914] Yeah, you know\n[02:00.129]\n[02:02.177] Dancing, I'm all alone\n[02:05.652] Figuring out how I can get you home\n[02:11.617] Dancing with my phone\n[02:14.687] Thinking about you\n[02:20.993] Dancing, I'm all alone (I'm dancing all alone)\n[02:24.218] Figuring out how I can get you home (How I can get you home)\n[02:30.291] Dancing with my phone (I'm dancing with my phone)\n[02:33.626] Thinking about you\n[02:37.376]\n[02:39.724] Dancing all alone, dancing all alone (I'm dancing all alone)\n[02:44.589] Dancing all alone, dancing all alone (I'm dancing with my phone)\n[02:49.284] Dancing with my phone\n[02:52.504] Thinking about you\n[02:58.522] Dancing all alone, dancing all alone\n[03:03.262] Dancing all alone, dancing all alone (Thinking about you)\n[03:08.094] Dancing with my phone\n[03:11.402] Thinking about you\n",
    }}
  />
)

APlayer displying scrolling lyrics

播放列表

audio 属性除了接收单个歌曲信息对象外,也可以接收包含多个歌曲信息的数组。当 audio 为数组时,会显示一个播放列表。

render(
  <APlayer
    audio={[
      {
        name: "Dancing with my phone",
        artist: "HYBS",
        url: "https://music.163.com/song/media/outer/url?id=1969744125",
        cover:
          "https://p1.music.126.net/tOtUdKjS9rktAFRamcomWQ==/109951167748733958.jpg",
        lrc: "[00:00.000] 作词 : James Alyn Wee/Kasidej Hongladaromp\n[00:01.000] 作曲 : James Alyn Wee/Kasidej Hongladaromp\n[00:28.836] I'm just laying on the floor again\n[00:33.124] Can't be bothered to get up now\n[00:36.345] I wouldn’t care\n[00:38.348] If I never get up again\n[00:41.363] I don’t want to\n[00:47.388] Then our song comes on the radio\n[00:51.906] Makes me wanna start to dance, oh\n[00:55.163] I wanna know\n[00:56.997] If you feel the same way as me\n[01:00.097] Why would you go?\n[01:02.695]\n[01:05.780] Dancing, I'm all alone\n[01:09.163] Figuring out how I can get you home\n[01:15.129] Dancing with my phone\n[01:18.393] Thinking about you\n[01:22.292]\n[01:25.154] On my feet and now I'm out the door\n[01:29.741] Walking by the places that we used to go\n[01:34.478] I remember all your favorite stores\n[01:37.743] I won't lie\n[01:43.345] I don't think I even know myself anymore\n[01:52.741] You're the one who knew me ****ing well\n[01:58.914] Yeah, you know\n[02:00.129]\n[02:02.177] Dancing, I'm all alone\n[02:05.652] Figuring out how I can get you home\n[02:11.617] Dancing with my phone\n[02:14.687] Thinking about you\n[02:20.993] Dancing, I'm all alone (I'm dancing all alone)\n[02:24.218] Figuring out how I can get you home (How I can get you home)\n[02:30.291] Dancing with my phone (I'm dancing with my phone)\n[02:33.626] Thinking about you\n[02:37.376]\n[02:39.724] Dancing all alone, dancing all alone (I'm dancing all alone)\n[02:44.589] Dancing all alone, dancing all alone (I'm dancing with my phone)\n[02:49.284] Dancing with my phone\n[02:52.504] Thinking about you\n[02:58.522] Dancing all alone, dancing all alone\n[03:03.262] Dancing all alone, dancing all alone (Thinking about you)\n[03:08.094] Dancing with my phone\n[03:11.402] Thinking about you\n",
      },
      {
        name: "僕は今日も",
        artist: "Vaundy",
        url: "https://music.163.com/song/media/outer/url?id=1441997419",
        cover:
          "https://p1.music.126.net/AnR2ejcBgGnOJXPsytivBQ==/109951164922366027.jpg",
        lrc: "[00:00.000] 作词 : Vaundy\n[00:00.002] 作曲 : Vaundy\n[00:00.04]僕は今日も - Vaundy\n[00:15.00]母さんが言ってたんだ\n[00:22.31]お前は才能があるから\n[00:29.06]「芸術家にでもなりな」と\n[00:35.98]また根拠の無い夢を語る\n[00:49.77]父さんが言ってたんだ\n[00:56.58]お前は親不孝だから\n[01:03.45]1人で生きていきなさい\n[01:08.69]また意味もわからず罵倒する\n[01:16.24]1人ではないと暗示をして\n[01:19.89]2人ではないとそう聞こえて\n[01:23.13]思ってるだけじゃ\n[01:24.88]そう 辛くてでも\n[01:26.72]そうする他にすべはなくて\n[01:29.85]愉快な日々だと暗示をして\n[01:33.38]不協和音が 聞こえてきた\n[01:36.80]抑えてるだけじゃ そう 辛くて\n[01:40.08]だから この気持ちを弾き語るよ\n[01:43.83]もしも僕らが生まれてきて\n[01:50.68]もしも僕らが大人になっても\n[01:57.48]もしも僕らがいなくなっていても\n[02:04.37]そこに僕の歌があれば\n[02:09.83]それでいいさ\n[02:18.90]彼女が言ってたんだ\n[02:25.69]あなたはカッコイイから\n[02:32.60]イケメンじゃなくていいんだよ\n[02:37.87]また元も子も無い言葉を君は言う\n[02:45.55]僕はできる子と暗示をして\n[02:48.87]心が折れる音が聞こえた\n[02:52.25]思ってるだけじゃ\n[02:54.02]そう 辛くてでも\n[02:55.86]そうする他にすべはなくて\n[02:59.12]明日は晴れると暗示をして\n[03:02.50]次の日は傘を持って行った\n[03:06.00]抑えてるだけじゃ そう 辛くて\n[03:09.10]だから この気持ちを弾き語るよ\n[03:12.96]もしも僕らが生まれてきて\n[03:19.77]もしも僕らが大人になっても\n[03:26.59]もしも僕らがいなくなっていても\n[03:33.52]そこに僕の歌があれば\n[03:38.92]それでいいさ\n[03:41.08]ピアノの音が聞こえる\n[03:47.91]ガラガラの声が聞こえる\n[03:54.01]枯れてく僕らの音楽に\n[03:57.08]飴をやって もう少しと\n[04:01.57]その気持ちを弾き語るよ\n[04:07.75]もしも僕らが生まれてきて\n[04:14.66]もしも僕らが大人になっても\n[04:21.58]もしも僕らがいなくなっていても\n[04:28.40]そこに僕の歌があれば\n[04:33.72]それでいいさ\n[04:35.48]もしも僕らに才能がなくて\n[04:42.13]もしも僕らが親孝行して\n[04:49.00]もしも僕らがイケていたら\n[04:54.74]ずっとそんなことを思ってさ\n[05:01.31]弾き語るよ\n",
      },
      ...
    ]}
  />
)

Screenshot of playlist

以上是几个常用功能的简介,完整的使用说明见 https://aplayer-react.js.org。

源码仓库见 https://github.com/SevenOutmanm/aplayer-react。

我如何使用 aplayer-react

我的博客使用 Gatsby 搭建,使用 Markdown 编写文章,并可以在 frontmatter 中存放一些信息,通过 GraphQL 查询。 在周记中,我将本周收藏的歌曲链接放到 frontmatter 中的 songs 字段。

例如:

---
title: 22w47
date: 2022-11-25
songs:
  - https://music.163.com/song?id=1969744125
  - https://music.163.com/song?id=1441997419
---

在文章页面组件中,使用 gatsby-transformer-remark 插件提供的 markdownRemark GraphQL 查询可以读取出 frontmatter 中的数据

export const pageQuery = `graphql
  query {
    markdownRemark(id: { eq: $id }) {
      frontmatter {
        title
        date(formatString: "MMMM DD, YYYY")
        songs
      }
    }
  }
`;

export default function Weekly({
  data: { markdownRemark }
}) {
  return (
      <NeteaseMusicPlayer songUrls={markdownRemark.frontmatter.songs} />
      ...
  )
}

这里的 NeteaseMusicPlayer 组件,是用于根据 songUrls 属性中的歌曲链接来获取歌曲的名称、歌手、封面图、歌词等信息。大致逻辑如下:

import { APlayer } from "aplayer-react"

export function NeteaseMusicPlayer({ songUrls }) {
  // 从歌曲分享链接中提取歌曲 id
  const songIds = useMemo(() => {
    return songUrls.map(url => getSongId(url))
  }, [songUrls])

  // 根据歌曲 id 查询歌曲详细信息、歌词
  const songInfos = useSongInfos(songIds)

  return <APlayer audio={songInfos} theme="auto" autoPlay />
}

useSongInfos 钩子中通过请求 NeteaseCloudMusicApi 来查询歌曲的详细信息和歌词。

export function useSongInfos(songIds) {
  // 初始返回歌曲的媒体播放地址,从而播放器可以先开始播放
  const [songInfos, setSongInfos] = useState(() =>
    songIds.map(id => composeMediaUrl(id))
  )

  useEffect(() => {
    // 获取歌曲详细信息
    fetch(`ncm.api/song/detail?ids=${songIds.join(",")}`)
      .then(response => response.json())
      .then(({ songs }) => {
        setSongInfos(
          songs.map(songInfo => {
            return {
              name: songInfo.name,
              artist: songInfo.ar.map(artist => artist.name).join("/"),
              url: `https://music.163.com/song/media/outer/url?id=${songInfo.id}`,
              cover: songInfo.al.picUrl,
            }
          })
        )

        songs.forEach((songInfo, index) => {
          // 获取歌曲歌词
          fetch(`ncm.api/lyric?id=${songInfo.id}`)
            .then(response => response.json())
            .then(({ lrc: { lyric } }) => {
              setSongInfos(prev => {
                const song = prev[index]
                return [
                  ...prev.slice(0, index),
                  {
                    ...song,
                    lrc: lyric,
                  },
                  ...prev.slice(index + 1),
                ]
              })
            })
        })
      })
  }, [songIds])

  return songInfos
}

效果如下:

Screenshot of 22w47

但是到目前为止,这样的实现存在一个小问题。在歌曲信息加载完成之前,播放器会短暂地显示缺省状态,观感上还是有些奇怪。

Screenshot of 22w47

既然每篇周记中包含的歌曲,在周记创建的时候就已经确定了,能否提前将歌曲的详细信息获取完直接静态地写进页面呢?刚好 Gatsby 提供了相关的能力。

Gatsby 允许在页面中通过编写 GraphQL 的形式查询数据用于展示,并且这个查询发生在构建阶段,查询到的数据直接以静态数据的形式写进页面。于是我们可以通过 Gatsby 提供的创建自定义 GraphQL 的能力,创建一个能够读取网易云音乐详情的 GraphQL 查询。

gatsby-node.js 中,添加 createSchemaCustomization 方法,来添加自定义的 GraphQL 类型声明。

// gatsby-node.js
/**
 * @type {import('gatsby').GatsbyNode['createSchemaCustomization']}
 */
exports.createSchemaCustomization = ({ actions: { createTypes } }) => {
  createTypes(`
    # Netease Cloud Music songs' info
    type NeteaseCloudMusicSong {
      id: Int
      name: String
      mediaUrl: String
      ar: [NeteaseCloudMusicArtist]
      al: NeteaseCloudMusicAlbum
      lrc: String
    }

    type NeteaseCloudMusicArtist {
      name: String
    }

    type NeteaseCloudMusicAlbum {
      picUrl: String
    }

    type MarkdownRemark implements Node {
      frontmatter: Frontmatter
    }

    type Frontmatter {
      songs: [NeteaseCloudMusicSong]
    }
  `)
}

这里我根据我所需要的歌曲详情信息,创建了歌曲信息类型 NeteaseCloudMusicSong。并扩展了 gatsby-transformer-remark 提供的 MarkdownRemark 类型,使得 frontmatter 中增加一个 songs 字段,来查询我们的 NeteaseCloudMusicSong 信息。

接着,添加 createResolvers 方法,来声明如何解析 songs 字段。

// gatsby-node.js
const ncmApi = require("NeteaseCloudMusicApi")
/**
 * @type {import('gatsby').GatsbyNode['createResolvers']}
 */
exports.createResolvers = ({ createResolvers }) => {
  const resolvers = {
    Frontmatter: {
      songs: {
        type: ["NeteaseCloudMusicSong"],
        resolve(source) {
          if (!source.songs) return source.songs

          // 从歌曲分享链接中提取歌曲 id
          const songIds = source.songs.map(url => getSongId(url))

          // 根据歌曲 id 查询歌曲详细信息
          return ncmApi
            .song_detail({ ids: songIds.join(",") })
            .then(response => response.body.songs)
        },
      },
    },
    NeteaseCloudMusicSong: {
      mediaUrl: {
        type: "String",
        resolve(source) {
          return composeMediaUrl(source.id)
        },
      },
      lrc: {
        type: "String",
        resolve(source) {
          return ncmApi
            .lyric({ id: source.id })
            .then(response => response.body.lrc.lyric)
        },
      },
    },
  }
  createResolvers(resolvers)
}

接着,在文章页面组件中,修改 GraphQL 查询,直接读取 songs 的各个详情字段。

export const pageQuery = `graphql
  query {
    markdownRemark(id: { eq: $id }) {
      frontmatter {
        title
        date(formatString: "MMMM DD, YYYY")
        songs {
          name
          ar {
            name
          }
          mediaUrl
          al {
            picUrl
          }
          lrc
        }
      }
    }
  }
`;

export default function Weekly({
  data: { markdownRemark }
}) {
  return (
      <NeteaseMusicPlayer songs={markdownRemark.frontmatter.songs} />
      ...
  )
}

最后,从 NeteaseMusicPlayer 组件移除请求 API 的逻辑,仅仅将 GraphQL 查询结果的结构转为 aplayer-react 接收的结构即可。

export function NeteaseMusicPlayer({ songs }) {
  return (
    <APlayer
      audio={songs.map(songInfo => ({
        name: songInfo.name,
        artist: songInfo.ar.map(artist => artist.name).join("/"),
        url: songInfo.mediaUrl,
        cover: songInfo.al.picUrl,
        lrc: songInfo.lrc,
      }))}
      theme="auto"
      autoPlay
    />
  )
}

这样一来,首次加载时播放器就已经具有完整的歌曲详细信息,不会再显示缺省状态。

结语

最后,欢迎大家 star 收藏 aplayer-react GitHub 仓库 ,也欢迎来我的博客留言。

祝过年好。


Profile picture

Written by Doma who just migrated his blog to Gatsby.js. You should follow him on Twitter and GitHub.