开发 `LyricTypewriter` 音乐播放组件

开发 `LyricTypewriter` 音乐播放组件

2025年06月30日
2773 字 · 18 分钟

📅 时间: 2025年6月30日

🎯 问题概览

  • 进度条控制点显示不全 - CSS布局问题



  • 进度条布局调整 - Flexbox重构



  • 歌词初始状态空白 - 体验优化



  • 播放按钮功能失效 - 事件冲突最坑



  • 客户端路由兼容性 - SPA初始化问题



  • 进度条拖拽歌词跳动 - 状态管理优化



🎨 Bug #1: 进度条控制点显示不全

🎯 问题描述

现象:进度条上的拖拽控制点只能看到上半部分,下半部分被切掉了。

点击查看详细的问题排查过程

首先怀疑是 CSS 的问题,检查了进度条相关样式:

CSS
.progress-bar-container {
  position: relative;
  width: 100%;
  height: 6px; /* 😱 高度太小了! */
  overflow: hidden; /* 😱 这个是罪魁祸首! */
  cursor: pointer;
}

.progress-handle {
  position: absolute;
  top: 50%;
  left: 0%;
  width: 12px;
  height: 12px; /* 控制点12px,但容器只有6px */
  /* ... */
}

根本原因

  1. 容器高度只有 6px,但控制点高度是 12px
  2. overflow: hidden 直接把超出部分裁剪掉了

✅ 修复方案

CSS
.progress-bar-container {
  position: relative;
  width: 100%;
  height: 22px; /* ✅ 增加高度容纳控制点 */
  /* overflow: hidden; ✅ 移除这行 */
  cursor: pointer;
  padding: 8px 0; /* ✅ 增加触摸区域 */
  margin: -8px 0; /* ✅ 保持视觉布局 */
  display: flex;
  align-items: center; /* ✅ 垂直居中 */
}

移动端适配

CSS
@media (max-width: 768px) {
  .progress-bar-container {
    height: 24px; /* 移动端更大的触摸区域 */
  }
  
  .progress-handle {
    width: 16px;
    height: 16px;
    opacity: 1; /* 移动端始终显示 */
  }
}

🎨 Bug #2: 进度条布局很奇怪

🎯 问题描述

原布局

PLAINTEXT
进度条
时间 - 时间

期望布局

PLAINTEXT
时间 - 进度条 - 时间

✅ 修复方案

重新设计 HTML 结构:

查看HTML结构对比
HTML
<!-- 原来的结构 -->
<div class="progress-container">
  <div class="progress-bar-container"><!-- 进度条 --></div>
  <div class="time-info">
    <span class="current-time">00:00</span>
    <span class="duration">00:00</span>
  </div>
</div>

<!-- ✅ 新的结构 -->
<div class="progress-container">
  <div class="progress-info">
    <span class="time-display">00:00</span>
    <div class="progress-bar-container"><!-- 进度条 --></div>
    <span class="time-display">00:00</span>
  </div>
</div>

Flexbox 布局:

CSS
.progress-info {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 1rem;
}

.time-display {
  flex-shrink: 0; /* ✅ 防止时间被压缩 */
  min-width: 40px; /* ✅ 保证最小宽度 */
  font-family: monospace; /* ✅ 等宽字体,避免跳动 */
}

.progress-bar-container {
  flex: 1; /* ✅ 占据剩余空间 */
}

🎭 Bug #3: 歌词初始状态空白

🎯 问题描述

现象:页面加载时歌词区域空白

✅ 修复思路

添加初始内容显示逻辑:

查看完整的歌词加载和显示逻辑
JAVASCRIPT
async loadLyrics() {
  try {
    const response = await fetch(this.lrcFile);
    const lrcText = await response.text();
    this.parseLrc(lrcText);
    
    // ✅ 歌词加载完成后立即显示初始内容
    this.displayInitialLyrics();
  } catch (error) {
    console.error("加载歌词失败:", error);
    // ✅ 失败时也要有友好提示
    this.currentLyricEl.textContent = "歌词加载失败,请检查文件路径";
    this.nextLyricEl.textContent = "点击播放按钮开始音乐 🎵";
  }
}

displayInitialLyrics() {
  if (this.lyrics.length > 0) {
    // ✅ 显示第一句歌词
    this.currentLyricEl.textContent = this.lyrics[0].text;
    this.currentLyricEl.classList.remove("typing-animation", "finished");
    
    // ✅ 显示下一句预览
    if (this.lyrics.length > 1) {
      this.nextLyricEl.textContent = `♪ ${this.lyrics[1].text}`;
    } else {
      this.nextLyricEl.textContent = "准备开始音乐之旅 🎶";
    }
  } else {
    // ✅ 无歌词时的友好提示
    this.currentLyricEl.textContent = "暂无歌词,享受纯音乐吧 🎵";
    this.nextLyricEl.textContent = "点击播放按钮开始音乐 🎶";
  }
}

💥 Bug #4: 播放按钮功能失效(最坑的一个)

🎯 问题描述

🔍 调试过程

第一步:添加详细日志

查看详细的调试日志代码
JAVASCRIPT
playAudio() {
  console.log("=== playAudio 被调用 ===");
  console.log("调用堆栈:", new Error().stack);
  console.log("audio元素:", this.audio);
  console.log("audioUrl:", this.audioUrl);
  // ... 播放逻辑
}

bindEvents() {
  console.log("开始绑定事件...");
  
  if (this.playBtn) {
    console.log("绑定播放按钮事件,按钮元素:", this.playBtn);
    console.log("按钮ID:", this.playBtn.id);
    
    this.playBtn.addEventListener("click", (event) => {
      console.log("=== 播放按钮被点击 ===");
      console.log("点击事件:", event);
      console.log("事件目标:", event.target);
      this.playAudio();
    });
    
    console.log("播放按钮事件已绑定完成");
  }
}

发现问题

  • 初始化日志正常
  • 事件绑定成功
  • 但点击按钮时 playAudio 没有被调用

第二步:怀疑自动播放冲突

原因分析

  1. 手动点击播放 → 触发 playAudio()
  2. 几乎同时,自动播放逻辑也被触发
  3. 两个 audio.play() 请求冲突,导致 AbortError

第三步:临时禁用自动播放

JAVASCRIPT
// ❌ 原来的代码
if (this.autoPlay && this.audioUrl) {
  this.setupAutoPlay();
}

// ✅ 临时注释掉
// if (this.autoPlay && this.audioUrl) {
//   this.setupAutoPlay();
// }

第四步:修改默认值

JAVASCRIPT
// ❌ 原来的默认值
const { lrcFile, audioUrl, speed = 50, autoPlay = true } = Astro.props;

// ✅ 改为默认不自动播放
const { lrcFile, audioUrl, speed = 50, autoPlay = false } = Astro.props;

🎯 根本原因分析

  1. 时序问题:初始化时歌词显示逻辑和自动播放逻辑产生竞争
  2. 音频API限制:浏览器不允许同时有多个 play() 请求
  3. 错误处理不当:AbortError 被 catch 块捕获,但没有正确处理

✅ 完整修复方案

查看完整的播放逻辑修复代码
JAVASCRIPT
playAudio() {
  // ✅ 检查当前播放状态
  if (this.isPlaying && this.audio) {
    this.pauseAudio();
    return;
  }
  
  if (this.audio && this.audioUrl) {
    // ✅ 确保音频完全停止
    if (!this.audio.paused) {
      this.audio.pause();
      this.audio.currentTime = 0;
    }
    
    // ✅ 设置音频源
    if (!this.audio.src || this.audio.src !== this.audioUrl) {
      this.audio.src = this.audioUrl;
    }
    
    // ✅ 播放音频
    this.audio
      .play()
      .then(() => {
        // 成功处理
        if (this.playBtn && this.pauseBtn) {
          this.playBtn.style.display = "none";
          this.pauseBtn.style.display = "inline-block";
        }
        this.startLyrics();
      })
      .catch((e) => {
        console.error("播放失败:", e);
        // ✅ 更好的错误处理
        if (e.name === 'AbortError') {
          console.warn("播放被中断,可能存在并发播放请求");
        }
      });
  }
}

🌐 Bug #5: 客户端路由兼容性问题

🎯 问题描述

🔍 根本原因

在 SPA(单页应用)中,DOMContentLoaded 事件只在首次加载时触发,路由切换时不会重新触发。

JAVASCRIPT
// ❌ 原来的初始化方式
document.addEventListener("DOMContentLoaded", () => {
  new LyricTypewriter(uniqueId, lrcFile, audioUrl, speed, autoPlay);
});

✅ 修复方案

多重初始化保障机制:

查看多重初始化保障机制的完整代码
JAVASCRIPT
// ✅ 新的初始化方式
function initializeComponent() {
  // 检查是否已初始化,避免重复
  const existingContainer = document.getElementById(uniqueId);
  if (existingContainer && existingContainer.dataset.initialized) {
    console.log("组件已初始化,跳过重复初始化");
    return;
  }
  
  console.log("开始初始化 LyricTypewriter 组件");
  const instance = new LyricTypewriter(uniqueId, lrcFile, audioUrl, speed, autoPlay);
  
  // 标记已初始化
  if (existingContainer) {
    existingContainer.dataset.initialized = "true";
  }
}

// 多种初始化策略
if (document.readyState === 'loading') {
  // 文档还在加载中
  document.addEventListener("DOMContentLoaded", initializeComponent);
} else {
  // 文档已加载完成(适用于客户端路由)
  initializeComponent();
}

// 延迟备用初始化
setTimeout(() => {
  const container = document.getElementById(uniqueId);
  if (container && !container.dataset.initialized) {
    console.log("延迟初始化组件");
    initializeComponent();
  }
}, 100);

🛡️ 防重复初始化机制

查看防重复初始化的类级别保护
JAVASCRIPT
class LyricTypewriter {
  constructor(containerId, lrcFile, audioUrl, speed, autoPlay) {
    // ...
    this.initialized = false; // ✅ 初始化标志
    this.initElements();
  }

  initElements() {
    // ✅ 防止重复初始化
    if (this.initialized) {
      return;
    }
    
    // ... 初始化逻辑
    
    // ✅ 标记为已初始化
    this.initialized = true;
  }

  bindEvents() {
    // ✅ 防止重复绑定事件
    if (this.eventsbound) {
      console.log("事件已绑定,跳过重复绑定");
      return;
    }
    
    // ... 事件绑定逻辑
    
    // ✅ 标记事件已绑定
    this.eventsbound = true;
  }
}

🎯 Bug #6: 进度条拖拽时歌词跳动

🎯 问题描述

🔍 问题分析

查看原有的歌词同步逻辑
JAVASCRIPT
syncLyrics() {
  // ... 查找当前歌词逻辑
  if (i !== this.currentIndex) {
    this.currentIndex = i;
    this.displayLyric(this.lyrics[i].text); // ❌ 总是使用打字机动画
    this.displayNextLyric(i + 1);
  }
}

displayLyric(text) {
  this.currentLyricEl.textContent = "";
  this.currentLyricEl.classList.remove("typing-animation", "finished");
  
  setTimeout(() => {
    this.typeText(this.currentLyricEl, text, this.speed); // ❌ 打字机动画
  }, 100);
}

根本原因:拖拽进度条时,歌词同步会重新触发打字机动画,导致跳动。

✅ 修复方案

第一步:添加拖拽状态管理

JAVASCRIPT
constructor(containerId, lrcFile, audioUrl, speed, autoPlay) {
  // ...
  this.isDragging = false; // ✅ 拖拽状态标志
}

第二步:新增直接显示方法

JAVASCRIPT
displayLyricDirect(text) {
  // ✅ 直接显示歌词,无打字机动画
  this.currentLyricEl.textContent = text;
  this.currentLyricEl.classList.remove("typing-animation");
  this.currentLyricEl.classList.add("finished");
}

第三步:智能选择显示模式

JAVASCRIPT
syncLyrics() {
  // ... 查找当前歌词逻辑
  if (i !== this.currentIndex) {
    this.currentIndex = i;
    
    // ✅ 根据拖拽状态选择显示方式
    if (this.isDragging) {
      // 拖拽时直接显示,避免跳动
      this.displayLyricDirect(this.lyrics[i].text);
    } else {
      // 正常播放时使用打字机动画
      this.displayLyric(this.lyrics[i].text);
    }
    
    this.displayNextLyric(i + 1);
  }
}

第四步:完善拖拽事件处理

查看完整的拖拽事件处理代码
JAVASCRIPT
bindEvents() {
  // ...
  if (this.progressBarContainer) {
    // ✅ 鼠标拖拽支持
    this.progressBarContainer.addEventListener("mousedown", (e) => {
      this.isDragging = true;
      this.handleProgressInteraction(e);
      
      const handleMouseMove = (e) => {
        if (this.isDragging) {
          this.handleProgressInteraction(e);
        }
      };
      
      const handleMouseUp = () => {
        this.isDragging = false; // ✅ 结束拖拽
        document.removeEventListener("mousemove", handleMouseMove);
        document.removeEventListener("mouseup", handleMouseUp);
      };
      
      document.addEventListener("mousemove", handleMouseMove);
      document.addEventListener("mouseup", handleMouseUp);
    });

    // ✅ 触摸拖拽支持
    this.progressBarContainer.addEventListener("touchstart", (e) => {
      e.preventDefault();
      this.isDragging = true;
      this.handleProgressInteraction(e.touches[0]);
    });

    this.progressBarContainer.addEventListener("touchend", () => {
      this.isDragging = false; // ✅ 结束拖拽
    });
  }
}

Ctrl + F5 强制刷新🚀


Thanks for reading!

开发 `LyricTypewriter` 音乐播放组件

2025年06月30日
2773 字 · 18 分钟