Lavender's Blog

浩瀚众星,皆降为尘

停一停,听文字说话

为 Shoka 右下角音乐播放器加入默认开启的桌面歌词开关,将当前 LRC 歌词以主题色浮层显示在屏幕底部,并同步播放、暂停、拖动进度、切歌和刷新后的状态。

#前言

Shoka 播放器原本可以在音乐面板内部滚动显示歌词,但关闭面板后就无法继续看到当前歌词。本次改造会复用播放器已有的 LRC 解析结果,在页面底部增加一条独立的桌面歌词浮层。

最终效果包含:

  • 在音乐面板控制栏中加入桌面歌词按钮;
  • 默认开启,用户关闭后记住选择;
  • 当前歌词显示在屏幕最下方;
  • 使用主题色文字、半透明背景和模糊效果提高可读性;
  • 播放时显示,暂停时自然隐藏;
  • 拖动进度条时立即同步歌词;
  • 切歌后重新解析新歌曲的 LRC;
  • 无歌词时不会显示空浮层;
  • 支持日间/夜间模式、移动端和减少动态效果设置;
  • 支持键盘操作与基础无障碍属性。

#开始前准备

  1. 找到博客根目录,并备份 themes/shoka/source/js/_app/player.js 与 themes/shoka/source/css/_common/components/tags/player.styl ;
  2. 确认右下角音乐播放器已经可以正常打开、播放和切歌;
  3. 至少准备一首带 LRC 歌词的音乐;
  4. 如果歌单由 Meting 接口返回,先确认返回对象中存在 lrc 字段;
  5. 本功能不需要安装新依赖;
  6. 修改完成后通过 hexo server 测试,不要直接编辑 public 目录。

桌面歌词依赖带时间标签的 LRC,例如 [01:23.45]歌词内容 。只有纯文本、没有时间标签的歌词无法跟随播放进度切换。

#文件结构

themes/shoka/source/js/_app/player.js
themes/shoka/source/css/_common/components/tags/player.styl
themes/shoka/_config.yml(只检查音乐数据,无需新增隐私配置)

#文件变更清单

操作文件具体变化
修改themes/shoka/source/js/_app/player.js增加歌词按钮、默认状态、浏览器存储、底部浮层、当前歌词同步、播放和暂停联动以及键盘操作。
修改themes/shoka/source/css/_common/components/tags/player.styl增加歌词按钮图标、状态圆点、激活样式、底部歌词浮层、暗色模式和移动端适配。
检查或补充音乐数据themes/shoka/_config.yml确保歌曲对象带有内联 LRC 或歌词 URL。教程只提供通用示例,不展示当前网站歌单。

本次没有删除播放器原有的面板歌词。面板歌词和桌面歌词共用同一份解析数据:前者负责完整上下文,后者只显示当前一句。

#实现原理

播放器原有的 lyrics.parse() 会把 LRC 转换成下面的二维数组:

[
  [0, '第一句歌词'],
  [8.35, '第二句歌词'],
  [15.7, '第三句歌词']
]

每次触发音频的 timeupdate 事件时,程序用当前播放秒数查找对应区间,并调用 setCurrent() :

  • 更新音乐面板中高亮的歌词行;
  • 把当前歌词保存到 currentText ;
  • 把文字同步到页面底部浮层;
  • 根据播放和开关状态决定浮层是否显示。

因此不需要创建第二套 LRC 解析器,也不会出现面板歌词和桌面歌词不同步的问题。

#实现

#第 1 步:把歌词按钮加入控制栏

打开 themes/shoka/source/js/_app/player.js ,在播放器默认控制列表中加入 lyrics :

controls: [
  'mode',
  'lyrics',
  'backward',
  'play-pause',
  'forward',
  'visualizer',
  'volume'
]

按钮点击事件调用歌词管理器:

events: {
  lyrics: function () {
    lyrics.toggle()
  }
}

只有右下角全局播放器需要桌面歌词。文章正文中的独立播放器应过滤 lyrics 控件,防止多个播放器同时争用一个底部浮层。

#第 2 步:设置默认状态

在 lyrics 对象中增加:

enabled: isToolPlayer && store.get('_PlayerLyrics') !== 'off'

这里使用 “不是 off 就开启” 的判断,所以用户第一次访问时默认开启。用户手动关闭后写入 _PlayerLyrics=off ,再次打开时删除该值。

toggle: function () {
  this.enabled = !this.enabled
  if (this.enabled) {
    store.del('_PlayerLyrics')
  } else {
    store.set('_PlayerLyrics', 'off')
  }
  this.syncButton()
  this.syncOverlay()
}

如果你希望默认关闭,可以把初始化条件改为:

enabled: isToolPlayer && store.get('_PlayerLyrics') === 'on'

并相应调整 toggle() 保存的值。但本文和当前项目均采用默认开启。

#第 3 步:动态创建底部歌词浮层

不需要修改页面模板。第一次初始化歌词时,由 JavaScript 在 <body> 内创建浮层:

ensureOverlay: function () {
  if (!isToolPlayer) return null
  if (!this.overlay) {
    this.overlay = $('#music-lyrics-overlay')
    if (!this.overlay) {
      this.overlay = BODY.createChild('div', {
        id: 'music-lyrics-overlay',
        className: 'music-lyrics-overlay',
        innerHTML: '<span></span>'
      })
      this.overlay.attr('role', 'status')
      this.overlay.attr('aria-live', 'polite')
      this.overlay.attr('aria-hidden', 'true')
    }
  }
  return this.overlay
}

aria-live="polite" 让辅助设备在合适的时机读取变化内容,不会强行打断当前操作。

#第 4 步:同步按钮状态

syncButton() 负责处理按钮外观和辅助属性:

var button = controller.btns.lyrics
button.toggleClass('active', this.enabled)
button.attr('aria-pressed', this.enabled ? 'true' : 'false')
button.attr('title', this.enabled ? '关闭桌面歌词' : '开启桌面歌词')

创建控制栏按钮时,再为歌词按钮增加键盘支持:

that.btns[item].tabIndex = 0
that.btns[item].attr('role', 'button')
that.btns[item].addEventListener('keydown', function (event) {
  if (event.key === 'Enter' || event.key === ' ') {
    event.preventDefault()
    event.currentTarget.click()
  }
})

#第 5 步:准备 LRC 歌词

如果使用网络歌单,接口返回的歌曲对象通常已经包含歌词。自己配置单曲时,可以使用内联 LRC:

audio:
  - title: 我的歌单
    list:
      - name: 示例歌曲
        artist: 示例歌手
        url: "https://example.com/music/demo.mp3"
        cover: "https://example.com/music/demo.jpg"
        lrc: |
          [00:00.00]示例歌曲
          [00:08.35]这是第一句歌词
          [00:15.70]这是第二句歌词

也可以把 lrc 设置为歌词文件地址:

lrc: "https://example.com/music/demo.lrc"

播放器会判断 lrc 是否以 http 开头:是 URL 就通过 fetch() 请求,否则直接解析内联文本。

远程歌词同样可能受 CORS 限制。请使用可信且允许跨域访问的歌词地址。不要直接载入来源不明的 LRC 内容。

#第 6 步:解析 LRC 时间标签

解析器支持下面两种精度:

[01:23.45]两位毫秒
[01:23.456]三位毫秒

核心转换方式如下:

var min2sec = oneTime[1] * 60
var sec2sec = parseInt(oneTime[2])
var msec2sec = oneTime[4]
  ? parseInt(oneTime[4]) / ((oneTime[4] + '').length === 2 ? 100 : 1000)
  : 0
var lrcTime = min2sec + sec2sec + msec2sec
lrc.push([lrcTime, lrcText])

一行歌词可以有多个时间标签,解析器会为每个时间建立记录,最后过滤空文本并按时间排序。

#第 7 步:在切歌时创建歌词

播放器渲染当前歌曲预览时,调用:

lyrics.create(this.el.child('.lrc'))

create() 会先记录当前歌曲索引,再异步获取歌词。如果请求返回时用户已经切到另一首歌,就放弃旧结果,防止上一首歌的歌词覆盖当前歌曲。

歌词切换时还要清空旧状态:

this.data = []
this.currentText = ''
this.syncOverlay()

#第 8 步:同步当前歌词

update(currentTime) 会找到当前播放时间所在的歌词区间:

if (
  currentTime >= this.data[i][0] &&
  (!this.data[i + 1] || currentTime < this.data[i + 1][0])
) {
  this.setCurrent(i)
}

setCurrent() 同时更新面板和桌面歌词:

this.index = index
this.currentText = this.data[index][1]
this.syncOverlay()

底部浮层只在下面四个条件全部成立时显示:

var visible =
  this.enabled &&
  !!this.currentText &&
  source &&
  !source.paused

因此关闭歌词、没有歌词、还未开始播放或者已经暂停时,都不会出现空白背景框。

#第 9 步:同步播放、暂停和拖动进度

音频播放状态变化时调用:

onplay: function () {
  lyrics.syncOverlay()
},
onpause: function () {
  lyrics.syncOverlay()
},
ontimeupdate: function () {
  lyrics.update(this.currentTime)
}

拖动进度条时也要立即更新:

var percentage = progress.percent(e, current)
progress.update(percentage)
lyrics.update(percentage * source.duration)

这样用户还没有松开鼠标时,歌词就能跟随预览位置变化。

#第 10 步:添加歌词按钮样式

打开 themes/shoka/source/css/_common/components/tags/player.styl ,为按钮增加图标和状态点:

.lyrics {
  position: relative;
  &::before {
    @extend .i-align-left:before;
  }
  &::after {
    content: "";
    position: absolute;
    width: .19rem;
    height: .19rem;
    border-radius: 50%;
    background: var(--grey-5);
  }
  &.active {
    color: var(--primary-color);
    &::after {
      background: var(--color-green);
      box-shadow: 0 0 .24rem var(--color-green);
    }
  }
}

关闭状态使用灰色圆点,开启状态统一使用绿色圆点。

#第 11 步:添加底部浮层样式

桌面歌词固定在屏幕底部中央:

.music-lyrics-overlay {
  position: fixed;
  left: 50%;
  bottom: 1.1rem;
  z-index: $zindex-3;
  max-width: calc(100vw - 8rem);
  padding: .5rem 1.15rem;
  color: var(--primary-color);
  background: var(--grey-1-a7);
  border: .0625rem solid var(--grey-9-a1);
  border-radius: 1rem;
  backdrop-filter: blur(.75rem) saturate(1.12);
  opacity: 0;
  pointer-events: none;
  transform: translate(-50%, 1rem) scale(.96);
  transition: opacity .32s ease, transform .38s cubic-bezier(.22, 1, .36, 1);
  &.show {
    opacity: 1;
    transform: translate(-50%, 0) scale(1);
  }
}

移动端需要把歌词稍微上移,避免挡住页面底部按钮:

+tablet-mobile() {
  bottom: 3.15rem;
  max-width: calc(100vw - 2rem);
  padding: .42rem .9rem;
}

暗色模式单独增加更深的半透明背景和阴影,保证浅色歌词仍然清晰。

#构建和验证

执行:

hexo clean
hexo generate --bail
hexo server

然后按下面顺序检查:

  1. 打开音乐面板,确认控制栏中出现歌词按钮;
  2. 第一次进入时状态点应为绿色;
  3. 播放带歌词的歌曲,当前歌词应显示在屏幕底部;
  4. 暂停后歌词应自然淡出,继续播放后恢复;
  5. 拖动进度条,歌词应立即跳转到对应位置;
  6. 切换歌曲,不能残留上一首歌的歌词;
  7. 关闭按钮并刷新页面,歌词应保持关闭;
  8. 重新开启后刷新,默认开启状态应恢复;
  9. 切换日间/夜间模式,确认文字和背景可读;
  10. 在手机尺寸下确认歌词不会遮挡右下角工具栏。

#常见问题

#播放器有歌曲,但底部没有歌词

先检查当前歌曲对象是否存在 lrc 字段,再检查 LRC 是否带时间标签。只有普通歌词文本时,程序无法知道每一句应该在什么时间显示。

#网络歌词请求失败

在控制台检查 URL、HTTPS 和 CORS。GitHub Pages 使用 HTTPS,HTTP 歌词地址可能被浏览器当作混合内容拦截。

#歌词时间不准确

这是 LRC 文件时间轴的问题。播放器按 LRC 中的时间切换,不会自动识别人声。需要修改歌词文件中的时间标签。

#切歌后仍显示上一首歌词

确保 lyrics.create() 开头记录了当前 playlist.index ,异步回调中再次比较索引;切歌时也要先清空 currentText 并调用 syncOverlay() 。

#歌词挡住移动端按钮

检查 +tablet-mobile() 是否把 bottom 提高到约 3.15rem 。如果网站底部还有其他固定组件,可以继续适当增加该值。

#刷新后开关状态没有保留

确认项目的 store 工具可以正常访问浏览器存储,并检查隐私模式或浏览器的跟踪防护是否禁止了本地存储。

#涉及改动的代码

代码卡片按属性名、函数名或带内容校验的具名片段定位,并显示当前源码行号。片段前后插行可自动重新定位;片段本身改变或位置不唯一时会停止生成,核对教程后再更新摘录,避免误引其他功能。

下面只展示桌面歌词功能涉及的代码区间,不展开整个播放器文件。卡片中的数字是当前项目原始行号;中间的省略提示代表未改动代码仍需保留。

主题配置只需要保证音乐数据中存在 lrc ,因此这里不读取 themes/shoka/_config.yml ,避免把当前网站歌单等个性化配置写进教程。

#歌词状态、解析和生命周期

themes/shoka/source/js/_app/player.js改动位置:第 8、28–30、697–912、920–924、955、1006–1034、1036–1091、1666–1684、1719–1723 行 335 行 · 10.4 KB
themes/shoka/source/js/_app/player.js
    controls: ['mode', 'lyrics', 'backward', 'play-pause', 'forward', 'visualizer', 'volume'],
⋯ 未改动代码已省略 ⋯
      "lyrics": function() {
        lyrics.toggle()
      }
⋯ 未改动代码已省略 ⋯
  var lyrics = {
    el: null,
    overlay: null,
    data: null,
    cache: {},
    pending: {},
    index: 0,
    currentText: '',
    enabled: isToolPlayer && store.get('_PlayerLyrics') !== 'off',
    ensureOverlay: function() {
      if(!isToolPlayer)
        return null
 
      if(!this.overlay) {
        this.overlay = $('#music-lyrics-overlay')
        if(!this.overlay) {
          this.overlay = BODY.createChild('div', {
            id: 'music-lyrics-overlay',
            className: 'music-lyrics-overlay',
            innerHTML: '<span></span>'
          })
          this.overlay.attr('role', 'status')
          this.overlay.attr('aria-live', 'polite')
          this.overlay.attr('aria-hidden', 'true')
        }
      }
 
      return this.overlay
    },
    syncButton: function() {
      if(!isToolPlayer || !controller.btns.lyrics)
        return
 
      var button = controller.btns.lyrics
      button.toggleClass('active', this.enabled)
      button.attr('aria-pressed', this.enabled ? 'true' : 'false')
      button.attr('title', this.enabled ? '关闭桌面歌词' : '开启桌面歌词')
    },
    syncOverlay: function() {
      if(!isToolPlayer)
        return
 
      var overlay = this.ensureOverlay()
      if(!overlay)
        return
 
      var visible = this.enabled && !!this.currentText && source && !source.paused
      overlay.child('span').textContent = this.currentText || ''
      overlay.toggleClass('show', visible)
      overlay.attr('aria-hidden', visible ? 'false' : 'true')
    },
    toggle: function() {
      if(!isToolPlayer)
        return
 
      this.enabled = !this.enabled
      if(this.enabled)
        store.del('_PlayerLyrics')
      else
        store.set('_PlayerLyrics', 'off')
 
      this.syncButton()
      this.syncOverlay()
      showtip(this.enabled ? '桌面歌词已开启' : '桌面歌词已关闭')
    },
    setCurrent: function(index) {
      if(!this.el || !this.data || !this.data[index])
        return
 
      var current = this.el.getElementsByClassName('current')[0]
      var lines = this.el.getElementsByTagName('p')
      current && current.removeClass('current')
      if(lines[index]) {
        lines[index].addClass('current')
        var y = -(index-1)
        this.el.style.transform = 'translateY('+y+'rem)'
        this.el.style.webkitTransform = 'translateY('+y+'rem)'
      }
 
      this.index = index
      this.currentText = this.data[index][1]
      this.syncOverlay()
    },
    create: function(box) {
      var current = playlist.current()
      var that = this
      var raw = playlist.current().lrc || ''
 
      this.data = []
      this.currentText = ''
      // Do not keep the previous song's seekable lyric lines while fetching.
      box.textContent = ''
      this.syncOverlay()
 
      var callback = function(body) {
        if(current !== playlist.current() || !box.isConnected)
          return;
 
        that.data = that.parse(body)
 
        that.el = box.createChild('div', {
          className: 'inner'
        }, 'replace')
        that.data.forEach(function(line, index) {
          var lyricLine = that.el.createChild('p', { className: index === 0 ? 'current' : '', textContent: line[1] })
          lyricLine.dataset.time = String(line[0])
        })
 
        that.index = 0
        if(that.data.length)
          that.setCurrent(0)
        else
          that.syncOverlay()
      }
 
      if(raw.startsWith('http'))
        this.fetch(raw, callback)
      else
        callback(raw)
    },
    update: function(currentTime) {
      if(!this.data || !this.data.length)
        return
 
      if (this.index > this.data.length - 1 || currentTime < this.data[this.index][0] || (!this.data[this.index + 1] || currentTime >= this.data[this.index + 1][0])) {
        for (var i = 0; i < this.data.length; i++) {
          if (currentTime >= this.data[i][0] && (!this.data[i + 1] || currentTime < this.data[i + 1][0])) {
            this.setCurrent(i)
            break
          }
        }
      }
    },
    parse: function(lrc_s) {
      if (lrc_s) {
          lrc_s = lrc_s.replace(/([^\]^\n])\[/g, function(match, p1){return p1 + '\n['});
          const lyric = lrc_s.split('\n');
          var lrc = [];
          const lyricLen = lyric.length;
          for (var i = 0; i < lyricLen; i++) {
              // match lrc time
              const lrcTimes = lyric[i].match(/\[(\d{2}):(\d{2})(\.(\d{2,3}))?]/g);
              // match lrc text
              const lrcText = lyric[i]
                  .replace(/.*\[(\d{2}):(\d{2})(\.(\d{2,3}))?]/g, '')
                  .replace(/<(\d{2}):(\d{2})(\.(\d{2,3}))?>/g, '')
                  .replace(/^\s+|\s+$/g, '');
 
              if (lrcTimes) {
                  // handle multiple time tag
                  const timeLen = lrcTimes.length;
                  for (var j = 0; j < timeLen; j++) {
                      const oneTime = /\[(\d{2}):(\d{2})(\.(\d{2,3}))?]/.exec(lrcTimes[j]);
                      const min2sec = oneTime[1] * 60;
                      const sec2sec = parseInt(oneTime[2]);
                      const msec2sec = oneTime[4] ? parseInt(oneTime[4]) / ((oneTime[4] + '').length === 2 ? 100 : 1000) : 0;
                      const lrcTime = min2sec + sec2sec + msec2sec;
                      lrc.push([lrcTime, lrcText]);
                  }
              }
          }
          // sort by time
          lrc = lrc.filter(function(item){return item[1]});
          lrc.sort(function(a, b){return a[0] - b[0]});
          return lrc;
      } else {
          return [];
      }
    },
    fetch: function(url, callback) {
      var that = this
      var key = utils.lyricCacheKey(url)
 
      if(Object.prototype.hasOwnProperty.call(this.cache, key)) {
        callback(this.cache[key])
        return
      }
 
      try {
        var stored = store.get(key)
        if(stored !== null) {
          this.cache[key] = stored
          callback(stored)
          return
        }
      } catch (error) {}
 
      if(!this.pending[key]) {
        var sources = utils.lyricSources(url)
        var request = function(index) {
          if(index >= sources.length)
            return Promise.resolve('')
 
          return utils.fetchWithTimeout(sources[index], 4500, 'text').then(function(body) {
            if(!body.trim()) throw new Error('Empty lyrics')
            return body
          }).catch(function() {
            return request(index + 1)
          })
        }
 
        this.pending[key] = request(0).then(function(body) {
          delete that.pending[key]
          if(body) {
            that.cache[key] = body
            try {
              store.set(key, body)
            } catch (error) {}
          }
          return body
        })
      }
 
      this.pending[key].then(callback)
    }
  }
⋯ 未改动代码已省略 ⋯
      this.el.innerHTML = '<div class="cover"><div class="disc"><img class="blur" alt="" decoding="async" /></div></div>'
      + '<div class="info"><h4 class="title"></h4><span></span>'
      + '<div class="lrc"></div></div>'
      this.el.querySelector('.info > .title').textContent = current.name
      this.el.querySelector('.info > span').textContent = current.artist
⋯ 未改动代码已省略 ⋯
      lyrics.create(this.el.child('.lrc'))
⋯ 未改动代码已省略 ⋯
    drag: function(e) {
      e.preventDefault()
 
      var current = playlist.current().el
 
      var thumbMove = function(e) {
        e.preventDefault()
        var percentage = progress.percent(e, current)
        progress.update(percentage)
        lyrics.update(percentage * source.duration);
      };
 
      var thumbUp = function(e) {
        e.preventDefault()
        current.removeEventListener(utils.nameMap.dragEnd, thumbUp)
        current.removeEventListener(utils.nameMap.dragMove, thumbMove)
        var percentage = progress.percent(e, current)
        progress.update(percentage)
        t.player.seek(percentage * source.duration)
        source.disableTimeupdate = false
        progress.seeking(false)
      };
 
      source.disableTimeupdate = true
      progress.seeking(true)
      current.addEventListener(utils.nameMap.dragMove, thumbMove)
      current.addEventListener(utils.nameMap.dragEnd, thumbUp)
    }
  }
⋯ 未改动代码已省略 ⋯
  var controller = {
    el: null,
    btns: {},
    step: 'next',
    create: function () {
      if(!t.player.options.controls)
        return
 
      var that = this
      t.player.options.controls.forEach(function(item) {
        if(that.btns[item])
          return;
 
        var opt = {
          onclick: function(event){
            that.events[item] ? that.events[item](event) : t.player.options.events[item](event)
          }
        }
 
        switch(item) {
          case 'volume':
            opt.className = ' ' + (source.muted ? 'off' : 'on')
            opt.innerHTML = '<div class="bar"></div>'
            opt['on'+utils.nameMap.dragStart] = that.events['volume']
            opt.onclick = null
            break;
          case 'mode':
            opt.className = ' ' + t.player.options.mode
            break;
          default:
            opt.className = ''
            break;
        }
 
        opt.className = item + opt.className + ' btn'
 
        that.btns[item] = that.el.createChild('div', opt)
 
        if(item === 'visualizer' || item === 'lyrics') {
          that.btns[item].tabIndex = 0
          that.btns[item].attr('role', 'button')
          that.btns[item].addEventListener('keydown', function(event) {
            if(event.key === 'Enter' || event.key === ' ') {
              event.preventDefault()
              event.currentTarget.click()
            }
          })
        }
      })
 
      that.btns['volume'].bar = that.btns['volume'].child('.bar')
      if(isToolPlayer) {
        lyrics.syncButton()
        visualizer.syncButton()
      }
    },
⋯ 未改动代码已省略 ⋯
    onplay: function() {
      t.parentNode.addClass('playing')
      visualizer.setPlaying(true)
      lyrics.syncOverlay()
      showtip(this.attr('title'), true)
      NOWPLAYING = t
    },
    onpause: function() {
      t.parentNode.removeClass('playing')
      visualizer.setPlaying(false)
      lyrics.syncOverlay()
      NOWPLAYING = null
    },
    ontimeupdate: function() {
      if(!this.disableTimeupdate) {
        progress.update(this.currentTime / this.duration)
        lyrics.update(this.currentTime)
      }
    },
⋯ 未改动代码已省略 ⋯
    t.player.options = Object.assign(option, config);
    if(!isToolPlayer && t.player.options.controls)
      t.player.options.controls = t.player.options.controls.filter(function(item) {
        return item !== 'visualizer' && item !== 'lyrics'
      })

#歌词按钮和底部浮层样式

themes/shoka/source/css/_common/components/tags/player.styl改动位置:第 27–50、107–159、334–381 行 125 行 · 2.9 KB
themes/shoka/source/css/_common/components/tags/player.styl
  .controller {
    font-family-icons();
    cursor: pointer;
    font-size: $font-size-larger;
    display: flex;
    justify-content: space-around;
    align-items: center;
    text-align: center;
 
    .btn {
      color: var(--grey-6);
      flex: 1 1 0;
      width: auto;
      min-width: 0;
      padding: .35rem 0;
      border-radius: .5rem;
      the-transition(.25s, ease-out);
 
      &:hover {
        color: var(--color-pink);
        background: var(--grey-1-a5);
      }
    }
  }
⋯ 未改动代码已省略 ⋯
  .lyrics {
    position: relative;
 
    &::before {
      @extend .i-align-left:before;
      display: inline-block;
      font-size: .82em;
      opacity: .84;
      transform: scaleX(.88);
      transform-origin: center;
      -webkit-font-smoothing: antialiased;
    }
 
    &::after {
      content: "";
      position: absolute;
      width: .19rem;
      height: .19rem;
      right: 28%;
      bottom: .34rem;
      border-radius: 50%;
      background: var(--grey-5);
      opacity: .68;
      transform: scale(.82);
      transition: opacity .25s ease, transform .25s ease, background .25s ease, box-shadow .25s ease;
    }
 
    &.active {
      color: var(--primary-color);
      background: unquote('rgba(var(--palette-primary-light-rgb, 171, 102, 255), .07)');
      text-shadow: 0 0 .22rem var(--color-pink-a3);
 
      &::before {
        opacity: .94;
      }
 
      &::after {
        background: var(--color-green);
        opacity: 1;
        transform: scale(1);
        box-shadow: 0 0 .24rem var(--color-green);
      }
    }
 
    &:focus {
      outline: none;
    }
 
    &:focus-visible {
      outline: .1rem solid var(--primary-color);
      outline-offset: .08rem;
    }
  }
⋯ 未改动代码已省略 ⋯
.music-lyrics-overlay {
  position: fixed;
  left: 50%;
  bottom: 1.1rem;
  z-index: $zindex-3;
  max-width: calc(100vw - 8rem);
  padding: .5rem 1.15rem;
  color: var(--primary-color);
  background: var(--grey-1-a7);
  border: .0625rem solid var(--grey-9-a1);
  border-radius: 1rem;
  box-shadow: 0 .35rem 1.25rem rgba(0, 0, 0, .16);
  backdrop-filter: blur(.75rem) saturate(1.12);
  font-size: $font-size-small;
  font-weight: 600;
  line-height: 1.65;
  letter-spacing: .025em;
  text-align: center;
  text-shadow: 0 0 .55rem var(--color-pink-a3);
  opacity: 0;
  pointer-events: none;
  transform: translate(-50%, 1rem) scale(.96);
  transition: opacity .32s ease, transform .38s cubic-bezier(.22, 1, .36, 1), background .3s ease, color .3s ease;
 
  &.show {
    opacity: 1;
    transform: translate(-50%, 0) scale(1);
  }
 
  +tablet-mobile() {
    bottom: 3.15rem;
    max-width: calc(100vw - 2rem);
    padding: .42rem .9rem;
    font-size: $font-size-smallest;
  }
}
 
[data-theme="dark"] .music-lyrics-overlay {
  background: rgba(35, 37, 46, .78);
  border-color: rgba(255, 255, 255, .08);
  box-shadow: 0 .4rem 1.4rem rgba(0, 0, 0, .32);
}
 
@media (prefers-reduced-motion: reduce) {
  .music-lyrics-overlay {
    transition-duration: .15s;
  }
}

完成!现在关闭音乐面板后仍能在屏幕底部看到与播放进度同步的当前歌词,并可以随时从播放器中开启或关闭。

更新于 阅读次数 … 次

请我喝[茶]~( ̄▽ ̄)~*

Lavender 微信支付

微信支付

Lavender 支付宝

支付宝