AudioPlayer.vue 11.4 KB
<!--
 * @Date: 2025-04-07 12:35:35
 * @LastEditors: hookehuyr hookehuyr@gmail.com
 * @LastEditTime: 2025-04-07 16:41:23
 * @FilePath: /mlaj/src/components/ui/audioPlayer.vue
 * @Description: 音频播放器组件,支持播放控制、进度条调节、音量控制、播放列表等功能
-->
<template>
  <!-- 音频播放器主容器 -->
  <div class="audio-player bg-white rounded-lg shadow-xl overflow-hidden max-w-3xl mx-auto p-4 sm:p-6">
    <!-- 封面与控制区:显示当前播放歌曲的封面、标题、艺术家和播放控制按钮 -->
    <div class="flex flex-col sm:flex-row items-center space-y-4 sm:space-y-0 sm:space-x-4">
      <!-- 歌曲封面 -->
      <div class="w-32 h-32 sm:w-24 sm:h-24 rounded-lg overflow-hidden">
        <img :src="currentSong.cover" alt="封面" class="w-full h-full object-cover">
      </div>

      <!-- 歌曲信息 -->
      <div class="flex-1 text-center sm:text-left">
        <h3 class="text-xl sm:text-lg font-medium">{{ currentSong.title }}</h3>
        <p class="text-sm text-gray-500">{{ currentSong.artist }}</p>
      </div>

      <!-- 播放控制按钮组:上一首、播放/暂停、下一首 -->
      <div class="flex items-center justify-center space-x-8 sm:space-x-6 w-full sm:w-auto">
        <button
          @click="prevSong"
          class="w-12 h-12 sm:w-10 sm:h-10 flex items-center justify-center rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
        >
          <font-awesome-icon icon="backward-step" class="text-xl text-gray-600" />
        </button>
        <button
          @click="togglePlay"
          :class="{'paused': !isPlaying, 'opacity-50 cursor-not-allowed': isLoading}"
          :disabled="isLoading"
          class="w-16 h-16 sm:w-14 sm:h-14 flex items-center justify-center rounded-full bg-blue-500 hover:bg-blue-600 transition-colors shadow-lg"
        >
          <font-awesome-icon
            :icon="['fas' , isPlaying ? 'pause' : 'play']"
            :class="{ 'fa-spin': isLoading }"
            class="text-3xl"
          />
        </button>
        <button
          @click="nextSong"
          class="w-12 h-12 sm:w-10 sm:h-10 flex items-center justify-center rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
        >
          <font-awesome-icon icon="forward-step" class="text-xl text-gray-600" />
        </button>
      </div>
    </div>

    <!-- 进度条与时间:显示当前播放时间、总时长和可拖动的进度条 -->
    <div class="mt-4">
      <div class="flex items-center justify-between text-sm text-gray-500">
        <span>{{ formatTime(currentTime) }}</span>
        <span>{{ formatTime(duration) }}</span>
      </div>

      <div class="progress-bar relative mt-2">
        <input
          type="range"
          :value="progress"
          @input="handleProgressChange"
          @change="seekTo"
          class="w-full appearance-none bg-gray-200 rounded-full h-1.5 cursor-pointer"
        >
        <div
          :style="{ width: `${progress}%` }"
          class="progress-track absolute left-0 top-0 h-full rounded-full bg-gradient-to-r from-blue-500 to-purple-500 transition-all"
        ></div>
      </div>
    </div>

    <!-- 音量与设置:音量控制滑块和播放列表按钮 -->
    <div class="flex items-center justify-between mt-6">
      <div class="flex items-center space-x-2">
        <!-- <font-awesome-icon :icon="volume === 0 ? 'volume-off' : 'volume-up'" />
        <input
          type="range"
          :value="volume"
          @input="handleVolumeChange"
          min="0"
          max="100"
          step="1"
          class="w-24 appearance-none bg-gray-200 rounded-full h-1.5 cursor-pointer"
        > -->
      </div>

      <div class="flex items-center space-x-4">
        <!-- 播放列表按钮 -->
        <button @click="togglePlaylist"><font-awesome-icon icon="list" /></button>
      </div>
    </div>

    <!-- 播放列表:可切换显示的歌曲列表面板 -->
    <div v-show="isPlaylistVisible" class="playlist mt-4 overscroll-contain">
      <div class="playlist-header flex justify-between items-center px-2 py-1 bg-gray-100 rounded-t-lg">
        <h4 class="font-medium">播放列表 ({{ songs.length }})</h4>
        <button @click="closePlaylist"><font-awesome-icon icon="xmark" /></button>
      </div>
      <div class="playlist-items" style="max-height: 16rem; overflow-y: auto; -webkit-overflow-scrolling: touch;">
        <div
          v-for="(song, index) in songs"
          :key="song.id"
          :class="{'active': index === currentIndex}"
          @click="selectSong(index)"
          class="px-2 py-3 hover:bg-gray-100 transition-colors"
        >
          {{ song.title }} - {{ song.artist }}
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { formatTime } from '@/utils/time'
import { wxInfo } from '@/utils/tools'

// 组件属性定义
const props = defineProps({
  songs: { type: Array, required: true }, // 音频列表数据
  initialIndex: { type: Number, default: 0 } // 初始播放索引
})

// 音频播放器状态管理
const audio = ref(null) // 音频实例
const isPlaying = ref(false) // 播放状态
const isLoading = ref(false) // 加载状态
const currentIndex = ref(props.initialIndex) // 当前播放索引
const currentTime = ref(0) // 当前播放时间
const duration = ref(0) // 音频总时长
const progress = ref(0) // 播放进度
const volume = ref(100) // 音量值
const isPlaylistVisible = ref(false) // 播放列表显示状态
const speed = ref(1.0) // 播放速度

// 计算属性
const currentSong = computed(() => props.songs[currentIndex.value]) // 当前播放的歌曲

// 生命周期钩子
onMounted(() => {
  loadAudio()
  audio.value?.addEventListener('timeupdate', updateProgress)
  audio.value?.addEventListener('ended', handleEnded)
})

// 核心方法:音频加载
const loadAudio = async () => {
  if (!currentSong.value) return
  isLoading.value = true
  try {
    if (audio.value) {
      audio.value.removeEventListener('timeupdate', updateProgress)
      audio.value.removeEventListener('ended', handleEnded)
    }
    audio.value = new Audio(currentSong.value.url)
    audio.value.volume = volume.value / 100
    audio.value.playbackRate = speed.value
    audio.value.addEventListener('timeupdate', updateProgress)
    audio.value.addEventListener('ended', handleEnded)
    await audio.value.load()
  } catch (error) {
    console.error('加载音频失败:', error)
  } finally {
    isLoading.value = false
  }
}

// 播放控制:切换播放/暂停状态
const togglePlay = async () => {
  if (isLoading.value) return
  try {
    if (!audio.value) {
      await loadAudio()
    }
    if (isPlaying.value) {
      await audio.value?.pause()
    } else {
      await audio.value?.play()
    }
    isPlaying.value = !isPlaying.value
  } catch (error) {
    console.error('播放控制失败:', error)
  }
}

// 进度更新
const updateProgress = () => {
  if (!audio.value) return
  currentTime.value = audio.value.currentTime
  duration.value = audio.value.duration
  progress.value = (currentTime.value / duration.value) * 100 || 0
}

// 播放结束处理
const handleEnded = () => {
  nextSong()
}

// 控制方法:切换到上一首
const prevSong = async () => {
  if (audio.value) {
    isPlaying.value = false
    await audio.value.pause()
    audio.value = null
  }
  currentIndex.value = (currentIndex.value - 1 + props.songs.length) % props.songs.length
  await loadAudio()
  if (audio.value) {
    await audio.value.play()
    // 使用非线性映射来调整音量变化的灵敏度
    const normalizedVolume = Math.pow(volume.value / 100, 2)
    audio.value.volume = normalizedVolume
    isPlaying.value = true
  }
}

// 控制方法:切换到下一首
const nextSong = async () => {
  if (audio.value) {
    isPlaying.value = false
    await audio.value.pause()
    audio.value = null
  }
  currentIndex.value = (currentIndex.value + 1) % props.songs.length
  await loadAudio()
  if (audio.value) {
    await audio.value.play()
    // 使用非线性映射来调整音量变化的灵敏度
    const normalizedVolume = Math.pow(volume.value / 100, 2)
    audio.value.volume = normalizedVolume
    isPlaying.value = true
  }
}

// 重新播放当前歌曲
const replaySong = () => {
  audio.value?.seek(0)
  togglePlay()
}

// 进度条交互处理
const handleProgressChange = (e) => {
  const target = e.target
  progress.value = parseFloat(target.value)
}

// 跳转到指定进度
const seekTo = () => {
  if (!audio.value || !duration.value) return
  audio.value.currentTime = (progress.value / 100) * duration.value
}

// 音量控制处理
const handleVolumeChange = (e) => {
  const target = e.target
  volume.value = parseFloat(target.value)
  // 使用非线性映射来调整音量变化的灵敏度
  const normalizedVolume = Math.pow(volume.value / 100, 2)
  if (audio.value) {
    audio.value.volume = normalizedVolume
  }
}

// 监听歌曲列表变化
watch(() => props.songs, () => {
  currentIndex.value = 0
  loadAudio()
}, { deep: true })

// 组件卸载时清理事件监听
onUnmounted(() => {
  audio.value?.removeEventListener('timeupdate', updateProgress)
  audio.value?.removeEventListener('ended', handleEnded)
})

// 播放列表相关方法
const togglePlaylist = () => {
  isPlaylistVisible.value = !isPlaylistVisible.value
}

const closePlaylist = () => {
  isPlaylistVisible.value = false
}

// 选择并播放指定歌曲
const selectSong = async (index) => {
  if (audio.value) {
    isPlaying.value = false
    await audio.value.pause()
    audio.value = null
  }
  currentIndex.value = index
  await loadAudio()
  if (audio.value) {
    await audio.value.play()
    isPlaying.value = true
    // 滚动到当前播放的音频
    const playlistItems = document.querySelector('.playlist-items')
    const activeItem = playlistItems?.querySelector('.active')
    if (playlistItems && activeItem) {
      activeItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
    }
  }
}

// 监听播放状态变化,确保当前播放项在可视区域内
watch([isPlaying, currentIndex], () => {
  if (isPlaying.value) {
    const playlistItems = document.querySelector('.playlist-items')
    const activeItem = playlistItems?.querySelector('.active')
    if (playlistItems && activeItem) {
      activeItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
    }
  }
})
</script>

<style scoped>
/* 音频播放器样式变量 */
.audio-player {
  --progress-height: 4px;
}

/* 进度条样式 */
.progress-bar {
  height: var(--progress-height);
  border-radius: var(--progress-height);
}

.progress-track {
  height: var(--progress-height);
  border-radius: var(--progress-height);
}

/* 按钮交互动画 */
button:not(:disabled) {
  transition: all 0.3s ease;
}

@media (hover: hover) {
  button:not(:disabled):hover {
    transform: scale(1.1);
  }
}

button.paused .fa-pause {
  animation: pulse 1.5s infinite;
}

button:disabled {
  cursor: not-allowed;
}

/* 暂停按钮动画 */
@keyframes pulse {
  0% { transform: scale(1); }
  50% { transform: scale(1.1); }
  100% { transform: scale(1); }
}

/* 播放列表样式 */
.playlist-items {
  border: 1px solid #e5e7eb;
  border-top: 0;
  border-radius: 0 0 0.375rem 0.375rem;
}

.active {
  background-color: #f3f4f6;
  font-weight: 500;
  color: #1a1a1a;
}

/* 移动端样式优化 */
@media (max-width: 640px) {
  .audio-player {
    --progress-height: 6px;
  }

  input[type="range"] {
    height: var(--progress-height);
  }
}
</style>