hookehuyr

feat(音频播放器): 添加音频播放器组件及功能

新增音频播放器组件,支持播放控制、进度条调节、音量控制、播放列表等功能。添加了FontAwesome图标库依赖,并更新了相关路由和页面配置。
......@@ -8,6 +8,9 @@
"name": "vue-vite",
"version": "0.0.0",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.5",
"@heroicons/vue": "^2.2.0",
"@vant/touch-emulator": "^1.4.0",
"@vant/use": "^1.6.0",
......@@ -830,6 +833,48 @@
"node": ">=18"
}
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.1.tgz",
"integrity": "sha512-GkWzv+L6d2bI5f/Vk6ikJ9xtl7dfXtoRu3YGE6nq0p/FFqA1ebMOAWg3XgRyb0I6LYyYkiAo+3/KrwuBp8xG7A==",
"hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.1.tgz",
"integrity": "sha512-MfRCYlQPXoLlpem+egxjfkEuP9UQswTrlCOsknus/NcMoblTH2g0jPrapbcIb04KGA7E2GZxbAccGZfWoYgsrQ==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.5.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.1.tgz",
"integrity": "sha512-S1PPfU3mIJa59biTtXJz1oI0+KAXW6bkAb31XKhxdxtuXDiUIFsih4JR1v5BbxY7hVHsD1RKq+jRkVRaf773NQ==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.5.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/vue-fontawesome": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.0.5.tgz",
"integrity": "sha512-isZZ4+utQH9qg9cWxWYHQ9GwI3r5FeO7GnmzKYV+gbjxcptQhh+F99iZXi1Y9AvFUEgy8kRpAdvDlbb3drWFrw==",
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~1 || ~6",
"vue": ">= 3.0.0 < 4"
}
},
"node_modules/@heroicons/vue": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
......
......@@ -15,6 +15,9 @@
"dev_upload": "npm run build_tar && npm run scp-dev && npm run dec-dev && npm run remove_tar"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.5",
"@heroicons/vue": "^2.2.0",
"@vant/touch-emulator": "^1.4.0",
"@vant/use": "^1.6.0",
......
......@@ -10,6 +10,7 @@ declare module 'vue' {
export interface GlobalComponents {
ActivityCard: typeof import('./components/ui/ActivityCard.vue')['default']
AppLayout: typeof import('./components/layout/AppLayout.vue')['default']
AudioPlayer: typeof import('./components/ui/AudioPlayer.vue')['default']
BottomNav: typeof import('./components/layout/BottomNav.vue')['default']
CheckInDialog: typeof import('./components/ui/CheckInDialog.vue')['default']
ConfirmDialog: typeof import('./components/ui/ConfirmDialog.vue')['default']
......
<!--
* @Date: 2025-04-07 12:35:35
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-04-07 14:30:26
* @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'
// 组件属性定义
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>
/*
* @Date: 2025-03-20 20:36:36
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-03-24 23:32:16
* @LastEditTime: 2025-04-07 14:24:50
* @FilePath: /mlaj/src/main.js
* @Description: 文件描述
*/
......@@ -13,10 +13,21 @@ import axios from '@/utils/axios';
import 'vant/lib/index.css'
import '@vant/touch-emulator';
/* import the fontawesome core */
import { library } from '@fortawesome/fontawesome-svg-core'
/* import font awesome icon component */
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
/* import specific icons */
import { faCirclePause, faCirclePlay, faPlay, faPause, faBackwardStep, faForwardStep, faVolumeUp, faRedo, faRepeat, faList, faChevronDown, faVolumeOff, faXmark } from '@fortawesome/free-solid-svg-icons'
/* add icons to the library */
library.add(faCirclePause, faCirclePlay, faPlay, faPause, faBackwardStep, faForwardStep, faVolumeUp, faRedo, faRepeat, faList, faChevronDown, faVolumeOff, faXmark)
const app = createApp(App)
// 屏蔽警告信息
app.config.warnHandler = () => null;
app.config.globalProperties.$http = axios; // 关键语句
app.component('font-awesome-icon', FontAwesomeIcon)
app.use(router)
app.mount('#app')
......
......@@ -163,6 +163,12 @@ export const routes = [
meta: { title: '修改密码' },
},
{
path: '/profile/settings/audio',
name: 'AudioPlayer',
component: () => import('../views/profile/settings/AudioPlayerPage.vue'),
meta: { title: '音频播放' },
},
{
path: '/profile/learning-records',
name: 'LearningRecords',
component: () => import('../views/profile/LearningRecordsPage.vue'),
......
/*
* @Date: 2025-04-07 12:41:59
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-04-07 12:42:05
* @FilePath: /mlaj/src/utils/time.js
* @Description: 文件描述
*/
/**
* 格式化时间戳为 mm:ss 或 hh:mm:ss 格式
* @param {number} seconds - 总秒数(支持小数)
* @returns {string} 格式化后的时间字符串
*/
export function formatTime(seconds) {
if (isNaN(seconds) || seconds < 0) return '0:00'
const hours = Math.floor(seconds / 3600)
seconds %= 3600
const minutes = Math.floor(seconds / 60)
seconds = Math.floor(seconds % 60)
const pad = (n) => n.toString().padStart(2, '0')
if (hours > 0) {
return `${hours}:${pad(minutes)}:${pad(seconds)}`
}
return `${minutes}:${pad(seconds)}`
}
......@@ -64,6 +64,17 @@
<ChevronRightIcon class="w-5 h-5 text-gray-400" />
</div>
</div>
<!-- 音频播放 -->
<div class="p-4" @click="router.push('/profile/settings/audio')">
<div class="flex items-center justify-between">
<div>
<h3 class="text-base font-medium text-gray-900">音频播放</h3>
<p class="text-sm text-gray-500">播放音频文件</p>
</div>
<ChevronRightIcon class="w-5 h-5 text-gray-400" />
</div>
</div>
</div>
</FrostedGlass>
</div>
......
<!--
* @Date: 2025-03-24 13:04:21
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-04-07 14:06:35
* @FilePath: /mlaj/src/views/profile/settings/AudioPlayerPage.vue
* @Description: 音频播放页面
-->
<template>
<AppLayout>
<div class="bg-gradient-to-br from-green-50 via-green-100/30 to-blue-50/30 min-h-screen">
<div class="px-4 py-6">
<FrostedGlass class="rounded-xl overflow-hidden">
<AudioPlayer :songs="audioList" />
</FrostedGlass>
</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref } from 'vue';
import AppLayout from '@/components/layout/AppLayout.vue';
import FrostedGlass from '@/components/ui/FrostedGlass.vue';
import AudioPlayer from '@/components/ui/AudioPlayer.vue';
// 测试音频数据
const audioList = ref([
{
id: 1,
title: '示例音频 1',
artist: '演唱者 1',
cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
url: 'https://img.tukuppt.com/newpreview_music/09/03/95/5c8af46b01eb138909.mp3'
},
{
id: 2,
title: '示例音频 2',
artist: '演唱者 2',
cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
url: 'https://img.tukuppt.com/newpreview_music/09/03/72/5c8ad96fbf47328809.mp3'
},
{
id: 3,
title: '示例音频 3',
artist: '演唱者 3',
cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
url: 'https://img.tukuppt.com/newpreview_music/09/03/95/5c8af46b01eb138909.mp3'
},
{
id: 4,
title: '示例音频 4',
artist: '演唱者 4',
cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
url: 'https://img.tukuppt.com/newpreview_music/09/03/72/5c8ad96fbf47328809.mp3'
},
{
id: 5,
title: '示例音频 5',
artist: '演唱者 5',
cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
url: 'https://img.tukuppt.com/newpreview_music/09/03/95/5c8af46b01eb138909.mp3'
},
{
id: 6,
title: '示例音频 6',
artist: '演唱者 6',
cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
url: 'https://img.tukuppt.com/newpreview_music/09/03/72/5c8ad96fbf47328809.mp3'
},
{
id: 7,
title: '示例音频 7',
artist: '演唱者 7',
cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
url: 'https://img.tukuppt.com/newpreview_music/09/03/95/5c8af46b01eb138909.mp3'
},
{
id: 8,
title: '示例音频 8',
artist: '演唱者 8',
cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
url: 'https://img.tukuppt.com/newpreview_music/09/03/72/5c8ad96fbf47328809.mp3'
},
]);
</script>
<!--
* @Date: 2025-03-20 19:53:12
* @Date: 2025-03-21 13:12:37
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-03-20 23:56:05
* @FilePath: /mlaj/src/App.vue
* @LastEditTime: 2025-04-07 12:57:28
* @FilePath: /mlaj/src/views/test.vue
* @Description: 文件描述
-->
<script setup>
const arr = [];
arr.push({ a: '1' });
arr.push({ a: '2', b: 'random1' });
arr.push({ a: '3', b: 'random2', c: true });
arr.push({ a: '4', b: 'random3', d: 123 });
arr.push({ a: '5', b: 'random4', e: new Date() });
const arr2 = arr.map(item => {
return {
...item,
b: 'random5'
}
})
console.warn(arr2);
</script>
<template>
</template>
<style>
</style>
......