feat(音频播放器): 添加音频播放器组件及功能
新增音频播放器组件,支持播放控制、进度条调节、音量控制、播放列表等功能。添加了FontAwesome图标库依赖,并更新了相关路由和页面配置。
Showing
10 changed files
with
579 additions
and
28 deletions
| ... | @@ -8,6 +8,9 @@ | ... | @@ -8,6 +8,9 @@ |
| 8 | "name": "vue-vite", | 8 | "name": "vue-vite", |
| 9 | "version": "0.0.0", | 9 | "version": "0.0.0", |
| 10 | "dependencies": { | 10 | "dependencies": { |
| 11 | + "@fortawesome/fontawesome-svg-core": "^6.5.1", | ||
| 12 | + "@fortawesome/free-solid-svg-icons": "^6.5.1", | ||
| 13 | + "@fortawesome/vue-fontawesome": "^3.0.5", | ||
| 11 | "@heroicons/vue": "^2.2.0", | 14 | "@heroicons/vue": "^2.2.0", |
| 12 | "@vant/touch-emulator": "^1.4.0", | 15 | "@vant/touch-emulator": "^1.4.0", |
| 13 | "@vant/use": "^1.6.0", | 16 | "@vant/use": "^1.6.0", |
| ... | @@ -830,6 +833,48 @@ | ... | @@ -830,6 +833,48 @@ |
| 830 | "node": ">=18" | 833 | "node": ">=18" |
| 831 | } | 834 | } |
| 832 | }, | 835 | }, |
| 836 | + "node_modules/@fortawesome/fontawesome-common-types": { | ||
| 837 | + "version": "6.5.1", | ||
| 838 | + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.1.tgz", | ||
| 839 | + "integrity": "sha512-GkWzv+L6d2bI5f/Vk6ikJ9xtl7dfXtoRu3YGE6nq0p/FFqA1ebMOAWg3XgRyb0I6LYyYkiAo+3/KrwuBp8xG7A==", | ||
| 840 | + "hasInstallScript": true, | ||
| 841 | + "engines": { | ||
| 842 | + "node": ">=6" | ||
| 843 | + } | ||
| 844 | + }, | ||
| 845 | + "node_modules/@fortawesome/fontawesome-svg-core": { | ||
| 846 | + "version": "6.5.1", | ||
| 847 | + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.1.tgz", | ||
| 848 | + "integrity": "sha512-MfRCYlQPXoLlpem+egxjfkEuP9UQswTrlCOsknus/NcMoblTH2g0jPrapbcIb04KGA7E2GZxbAccGZfWoYgsrQ==", | ||
| 849 | + "hasInstallScript": true, | ||
| 850 | + "dependencies": { | ||
| 851 | + "@fortawesome/fontawesome-common-types": "6.5.1" | ||
| 852 | + }, | ||
| 853 | + "engines": { | ||
| 854 | + "node": ">=6" | ||
| 855 | + } | ||
| 856 | + }, | ||
| 857 | + "node_modules/@fortawesome/free-solid-svg-icons": { | ||
| 858 | + "version": "6.5.1", | ||
| 859 | + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.1.tgz", | ||
| 860 | + "integrity": "sha512-S1PPfU3mIJa59biTtXJz1oI0+KAXW6bkAb31XKhxdxtuXDiUIFsih4JR1v5BbxY7hVHsD1RKq+jRkVRaf773NQ==", | ||
| 861 | + "hasInstallScript": true, | ||
| 862 | + "dependencies": { | ||
| 863 | + "@fortawesome/fontawesome-common-types": "6.5.1" | ||
| 864 | + }, | ||
| 865 | + "engines": { | ||
| 866 | + "node": ">=6" | ||
| 867 | + } | ||
| 868 | + }, | ||
| 869 | + "node_modules/@fortawesome/vue-fontawesome": { | ||
| 870 | + "version": "3.0.5", | ||
| 871 | + "resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.0.5.tgz", | ||
| 872 | + "integrity": "sha512-isZZ4+utQH9qg9cWxWYHQ9GwI3r5FeO7GnmzKYV+gbjxcptQhh+F99iZXi1Y9AvFUEgy8kRpAdvDlbb3drWFrw==", | ||
| 873 | + "peerDependencies": { | ||
| 874 | + "@fortawesome/fontawesome-svg-core": "~1 || ~6", | ||
| 875 | + "vue": ">= 3.0.0 < 4" | ||
| 876 | + } | ||
| 877 | + }, | ||
| 833 | "node_modules/@heroicons/vue": { | 878 | "node_modules/@heroicons/vue": { |
| 834 | "version": "2.2.0", | 879 | "version": "2.2.0", |
| 835 | "resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz", | 880 | "resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz", | ... | ... |
| ... | @@ -15,6 +15,9 @@ | ... | @@ -15,6 +15,9 @@ |
| 15 | "dev_upload": "npm run build_tar && npm run scp-dev && npm run dec-dev && npm run remove_tar" | 15 | "dev_upload": "npm run build_tar && npm run scp-dev && npm run dec-dev && npm run remove_tar" |
| 16 | }, | 16 | }, |
| 17 | "dependencies": { | 17 | "dependencies": { |
| 18 | + "@fortawesome/fontawesome-svg-core": "^6.5.1", | ||
| 19 | + "@fortawesome/free-solid-svg-icons": "^6.5.1", | ||
| 20 | + "@fortawesome/vue-fontawesome": "^3.0.5", | ||
| 18 | "@heroicons/vue": "^2.2.0", | 21 | "@heroicons/vue": "^2.2.0", |
| 19 | "@vant/touch-emulator": "^1.4.0", | 22 | "@vant/touch-emulator": "^1.4.0", |
| 20 | "@vant/use": "^1.6.0", | 23 | "@vant/use": "^1.6.0", | ... | ... |
| ... | @@ -10,6 +10,7 @@ declare module 'vue' { | ... | @@ -10,6 +10,7 @@ declare module 'vue' { |
| 10 | export interface GlobalComponents { | 10 | export interface GlobalComponents { |
| 11 | ActivityCard: typeof import('./components/ui/ActivityCard.vue')['default'] | 11 | ActivityCard: typeof import('./components/ui/ActivityCard.vue')['default'] |
| 12 | AppLayout: typeof import('./components/layout/AppLayout.vue')['default'] | 12 | AppLayout: typeof import('./components/layout/AppLayout.vue')['default'] |
| 13 | + AudioPlayer: typeof import('./components/ui/AudioPlayer.vue')['default'] | ||
| 13 | BottomNav: typeof import('./components/layout/BottomNav.vue')['default'] | 14 | BottomNav: typeof import('./components/layout/BottomNav.vue')['default'] |
| 14 | CheckInDialog: typeof import('./components/ui/CheckInDialog.vue')['default'] | 15 | CheckInDialog: typeof import('./components/ui/CheckInDialog.vue')['default'] |
| 15 | ConfirmDialog: typeof import('./components/ui/ConfirmDialog.vue')['default'] | 16 | ConfirmDialog: typeof import('./components/ui/ConfirmDialog.vue')['default'] | ... | ... |
src/components/ui/AudioPlayer.vue
0 → 100644
| 1 | +<!-- | ||
| 2 | + * @Date: 2025-04-07 12:35:35 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2025-04-07 14:30:26 | ||
| 5 | + * @FilePath: /mlaj/src/components/ui/audioPlayer.vue | ||
| 6 | + * @Description: 音频播放器组件,支持播放控制、进度条调节、音量控制、播放列表等功能 | ||
| 7 | +--> | ||
| 8 | +<template> | ||
| 9 | + <!-- 音频播放器主容器 --> | ||
| 10 | + <div class="audio-player bg-white rounded-lg shadow-xl overflow-hidden max-w-3xl mx-auto p-4 sm:p-6"> | ||
| 11 | + <!-- 封面与控制区:显示当前播放歌曲的封面、标题、艺术家和播放控制按钮 --> | ||
| 12 | + <div class="flex flex-col sm:flex-row items-center space-y-4 sm:space-y-0 sm:space-x-4"> | ||
| 13 | + <!-- 歌曲封面 --> | ||
| 14 | + <div class="w-32 h-32 sm:w-24 sm:h-24 rounded-lg overflow-hidden"> | ||
| 15 | + <img :src="currentSong.cover" alt="封面" class="w-full h-full object-cover"> | ||
| 16 | + </div> | ||
| 17 | + | ||
| 18 | + <!-- 歌曲信息 --> | ||
| 19 | + <div class="flex-1 text-center sm:text-left"> | ||
| 20 | + <h3 class="text-xl sm:text-lg font-medium">{{ currentSong.title }}</h3> | ||
| 21 | + <p class="text-sm text-gray-500">{{ currentSong.artist }}</p> | ||
| 22 | + </div> | ||
| 23 | + | ||
| 24 | + <!-- 播放控制按钮组:上一首、播放/暂停、下一首 --> | ||
| 25 | + <div class="flex items-center justify-center space-x-8 sm:space-x-6 w-full sm:w-auto"> | ||
| 26 | + <button | ||
| 27 | + @click="prevSong" | ||
| 28 | + 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" | ||
| 29 | + > | ||
| 30 | + <font-awesome-icon icon="backward-step" class="text-xl text-gray-600" /> | ||
| 31 | + </button> | ||
| 32 | + <button | ||
| 33 | + @click="togglePlay" | ||
| 34 | + :class="{'paused': !isPlaying, 'opacity-50 cursor-not-allowed': isLoading}" | ||
| 35 | + :disabled="isLoading" | ||
| 36 | + 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" | ||
| 37 | + > | ||
| 38 | + <font-awesome-icon | ||
| 39 | + :icon="['fas' , isPlaying ? 'pause' : 'play']" | ||
| 40 | + :class="{ 'fa-spin': isLoading }" | ||
| 41 | + class="text-3xl" | ||
| 42 | + /> | ||
| 43 | + </button> | ||
| 44 | + <button | ||
| 45 | + @click="nextSong" | ||
| 46 | + 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" | ||
| 47 | + > | ||
| 48 | + <font-awesome-icon icon="forward-step" class="text-xl text-gray-600" /> | ||
| 49 | + </button> | ||
| 50 | + </div> | ||
| 51 | + </div> | ||
| 52 | + | ||
| 53 | + <!-- 进度条与时间:显示当前播放时间、总时长和可拖动的进度条 --> | ||
| 54 | + <div class="mt-4"> | ||
| 55 | + <div class="flex items-center justify-between text-sm text-gray-500"> | ||
| 56 | + <span>{{ formatTime(currentTime) }}</span> | ||
| 57 | + <span>{{ formatTime(duration) }}</span> | ||
| 58 | + </div> | ||
| 59 | + | ||
| 60 | + <div class="progress-bar relative mt-2"> | ||
| 61 | + <input | ||
| 62 | + type="range" | ||
| 63 | + :value="progress" | ||
| 64 | + @input="handleProgressChange" | ||
| 65 | + @change="seekTo" | ||
| 66 | + class="w-full appearance-none bg-gray-200 rounded-full h-1.5 cursor-pointer" | ||
| 67 | + > | ||
| 68 | + <div | ||
| 69 | + :style="{ width: `${progress}%` }" | ||
| 70 | + class="progress-track absolute left-0 top-0 h-full rounded-full bg-gradient-to-r from-blue-500 to-purple-500 transition-all" | ||
| 71 | + ></div> | ||
| 72 | + </div> | ||
| 73 | + </div> | ||
| 74 | + | ||
| 75 | + <!-- 音量与设置:音量控制滑块和播放列表按钮 --> | ||
| 76 | + <div class="flex items-center justify-between mt-6"> | ||
| 77 | + <div class="flex items-center space-x-2"> | ||
| 78 | + <font-awesome-icon :icon="volume === 0 ? 'volume-off' : 'volume-up'" /> | ||
| 79 | + <input | ||
| 80 | + type="range" | ||
| 81 | + :value="volume" | ||
| 82 | + @input="handleVolumeChange" | ||
| 83 | + min="0" | ||
| 84 | + max="100" | ||
| 85 | + step="1" | ||
| 86 | + class="w-24 appearance-none bg-gray-200 rounded-full h-1.5 cursor-pointer" | ||
| 87 | + > | ||
| 88 | + </div> | ||
| 89 | + | ||
| 90 | + <div class="flex items-center space-x-4"> | ||
| 91 | + <!-- 播放列表按钮 --> | ||
| 92 | + <button @click="togglePlaylist"><font-awesome-icon icon="list" /></button> | ||
| 93 | + </div> | ||
| 94 | + </div> | ||
| 95 | + | ||
| 96 | + <!-- 播放列表:可切换显示的歌曲列表面板 --> | ||
| 97 | + <div v-show="isPlaylistVisible" class="playlist mt-4 overscroll-contain"> | ||
| 98 | + <div class="playlist-header flex justify-between items-center px-2 py-1 bg-gray-100 rounded-t-lg"> | ||
| 99 | + <h4 class="font-medium">播放列表 ({{ songs.length }})</h4> | ||
| 100 | + <button @click="closePlaylist"><font-awesome-icon icon="xmark" /></button> | ||
| 101 | + </div> | ||
| 102 | + <div class="playlist-items" style="max-height: 16rem; overflow-y: auto; -webkit-overflow-scrolling: touch;"> | ||
| 103 | + <div | ||
| 104 | + v-for="(song, index) in songs" | ||
| 105 | + :key="song.id" | ||
| 106 | + :class="{'active': index === currentIndex}" | ||
| 107 | + @click="selectSong(index)" | ||
| 108 | + class="px-2 py-3 hover:bg-gray-100 transition-colors" | ||
| 109 | + > | ||
| 110 | + {{ song.title }} - {{ song.artist }} | ||
| 111 | + </div> | ||
| 112 | + </div> | ||
| 113 | + </div> | ||
| 114 | + </div> | ||
| 115 | +</template> | ||
| 116 | + | ||
| 117 | +<script setup> | ||
| 118 | +import { ref, computed, onMounted, watch } from 'vue' | ||
| 119 | +import { formatTime } from '@/utils/time' | ||
| 120 | + | ||
| 121 | +// 组件属性定义 | ||
| 122 | +const props = defineProps({ | ||
| 123 | + songs: { type: Array, required: true }, // 音频列表数据 | ||
| 124 | + initialIndex: { type: Number, default: 0 } // 初始播放索引 | ||
| 125 | +}) | ||
| 126 | + | ||
| 127 | +// 音频播放器状态管理 | ||
| 128 | +const audio = ref(null) // 音频实例 | ||
| 129 | +const isPlaying = ref(false) // 播放状态 | ||
| 130 | +const isLoading = ref(false) // 加载状态 | ||
| 131 | +const currentIndex = ref(props.initialIndex) // 当前播放索引 | ||
| 132 | +const currentTime = ref(0) // 当前播放时间 | ||
| 133 | +const duration = ref(0) // 音频总时长 | ||
| 134 | +const progress = ref(0) // 播放进度 | ||
| 135 | +const volume = ref(100) // 音量值 | ||
| 136 | +const isPlaylistVisible = ref(false) // 播放列表显示状态 | ||
| 137 | +const speed = ref(1.0) // 播放速度 | ||
| 138 | + | ||
| 139 | +// 计算属性 | ||
| 140 | +const currentSong = computed(() => props.songs[currentIndex.value]) // 当前播放的歌曲 | ||
| 141 | + | ||
| 142 | +// 生命周期钩子 | ||
| 143 | +onMounted(() => { | ||
| 144 | + loadAudio() | ||
| 145 | + audio.value?.addEventListener('timeupdate', updateProgress) | ||
| 146 | + audio.value?.addEventListener('ended', handleEnded) | ||
| 147 | +}) | ||
| 148 | + | ||
| 149 | +// 核心方法:音频加载 | ||
| 150 | +const loadAudio = async () => { | ||
| 151 | + if (!currentSong.value) return | ||
| 152 | + isLoading.value = true | ||
| 153 | + try { | ||
| 154 | + if (audio.value) { | ||
| 155 | + audio.value.removeEventListener('timeupdate', updateProgress) | ||
| 156 | + audio.value.removeEventListener('ended', handleEnded) | ||
| 157 | + } | ||
| 158 | + audio.value = new Audio(currentSong.value.url) | ||
| 159 | + audio.value.volume = volume.value / 100 | ||
| 160 | + audio.value.playbackRate = speed.value | ||
| 161 | + audio.value.addEventListener('timeupdate', updateProgress) | ||
| 162 | + audio.value.addEventListener('ended', handleEnded) | ||
| 163 | + await audio.value.load() | ||
| 164 | + } catch (error) { | ||
| 165 | + console.error('加载音频失败:', error) | ||
| 166 | + } finally { | ||
| 167 | + isLoading.value = false | ||
| 168 | + } | ||
| 169 | +} | ||
| 170 | + | ||
| 171 | +// 播放控制:切换播放/暂停状态 | ||
| 172 | +const togglePlay = async () => { | ||
| 173 | + if (isLoading.value) return | ||
| 174 | + try { | ||
| 175 | + if (!audio.value) { | ||
| 176 | + await loadAudio() | ||
| 177 | + } | ||
| 178 | + if (isPlaying.value) { | ||
| 179 | + await audio.value?.pause() | ||
| 180 | + } else { | ||
| 181 | + await audio.value?.play() | ||
| 182 | + } | ||
| 183 | + isPlaying.value = !isPlaying.value | ||
| 184 | + } catch (error) { | ||
| 185 | + console.error('播放控制失败:', error) | ||
| 186 | + } | ||
| 187 | +} | ||
| 188 | + | ||
| 189 | +// 进度更新 | ||
| 190 | +const updateProgress = () => { | ||
| 191 | + if (!audio.value) return | ||
| 192 | + currentTime.value = audio.value.currentTime | ||
| 193 | + duration.value = audio.value.duration | ||
| 194 | + progress.value = (currentTime.value / duration.value) * 100 || 0 | ||
| 195 | +} | ||
| 196 | + | ||
| 197 | +// 播放结束处理 | ||
| 198 | +const handleEnded = () => { | ||
| 199 | + nextSong() | ||
| 200 | +} | ||
| 201 | + | ||
| 202 | +// 控制方法:切换到上一首 | ||
| 203 | +const prevSong = async () => { | ||
| 204 | + if (audio.value) { | ||
| 205 | + isPlaying.value = false | ||
| 206 | + await audio.value.pause() | ||
| 207 | + audio.value = null | ||
| 208 | + } | ||
| 209 | + currentIndex.value = (currentIndex.value - 1 + props.songs.length) % props.songs.length | ||
| 210 | + await loadAudio() | ||
| 211 | + if (audio.value) { | ||
| 212 | + await audio.value.play() | ||
| 213 | + // 使用非线性映射来调整音量变化的灵敏度 | ||
| 214 | + const normalizedVolume = Math.pow(volume.value / 100, 2) | ||
| 215 | + audio.value.volume = normalizedVolume | ||
| 216 | + isPlaying.value = true | ||
| 217 | + } | ||
| 218 | +} | ||
| 219 | + | ||
| 220 | +// 控制方法:切换到下一首 | ||
| 221 | +const nextSong = async () => { | ||
| 222 | + if (audio.value) { | ||
| 223 | + isPlaying.value = false | ||
| 224 | + await audio.value.pause() | ||
| 225 | + audio.value = null | ||
| 226 | + } | ||
| 227 | + currentIndex.value = (currentIndex.value + 1) % props.songs.length | ||
| 228 | + await loadAudio() | ||
| 229 | + if (audio.value) { | ||
| 230 | + await audio.value.play() | ||
| 231 | + // 使用非线性映射来调整音量变化的灵敏度 | ||
| 232 | + const normalizedVolume = Math.pow(volume.value / 100, 2) | ||
| 233 | + audio.value.volume = normalizedVolume | ||
| 234 | + isPlaying.value = true | ||
| 235 | + } | ||
| 236 | +} | ||
| 237 | + | ||
| 238 | +// 重新播放当前歌曲 | ||
| 239 | +const replaySong = () => { | ||
| 240 | + audio.value?.seek(0) | ||
| 241 | + togglePlay() | ||
| 242 | +} | ||
| 243 | + | ||
| 244 | +// 进度条交互处理 | ||
| 245 | +const handleProgressChange = (e) => { | ||
| 246 | + const target = e.target | ||
| 247 | + progress.value = parseFloat(target.value) | ||
| 248 | +} | ||
| 249 | + | ||
| 250 | +// 跳转到指定进度 | ||
| 251 | +const seekTo = () => { | ||
| 252 | + if (!audio.value || !duration.value) return | ||
| 253 | + audio.value.currentTime = (progress.value / 100) * duration.value | ||
| 254 | +} | ||
| 255 | + | ||
| 256 | +// 音量控制处理 | ||
| 257 | +const handleVolumeChange = (e) => { | ||
| 258 | + const target = e.target | ||
| 259 | + volume.value = parseFloat(target.value) | ||
| 260 | + // 使用非线性映射来调整音量变化的灵敏度 | ||
| 261 | + const normalizedVolume = Math.pow(volume.value / 100, 2) | ||
| 262 | + if (audio.value) { | ||
| 263 | + audio.value.volume = normalizedVolume | ||
| 264 | + } | ||
| 265 | +} | ||
| 266 | + | ||
| 267 | +// 监听歌曲列表变化 | ||
| 268 | +watch(() => props.songs, () => { | ||
| 269 | + currentIndex.value = 0 | ||
| 270 | + loadAudio() | ||
| 271 | +}, { deep: true }) | ||
| 272 | + | ||
| 273 | +// 组件卸载时清理事件监听 | ||
| 274 | +onUnmounted(() => { | ||
| 275 | + audio.value?.removeEventListener('timeupdate', updateProgress) | ||
| 276 | + audio.value?.removeEventListener('ended', handleEnded) | ||
| 277 | +}) | ||
| 278 | + | ||
| 279 | +// 播放列表相关方法 | ||
| 280 | +const togglePlaylist = () => { | ||
| 281 | + isPlaylistVisible.value = !isPlaylistVisible.value | ||
| 282 | +} | ||
| 283 | + | ||
| 284 | +const closePlaylist = () => { | ||
| 285 | + isPlaylistVisible.value = false | ||
| 286 | +} | ||
| 287 | + | ||
| 288 | +// 选择并播放指定歌曲 | ||
| 289 | +const selectSong = async (index) => { | ||
| 290 | + if (audio.value) { | ||
| 291 | + isPlaying.value = false | ||
| 292 | + await audio.value.pause() | ||
| 293 | + audio.value = null | ||
| 294 | + } | ||
| 295 | + currentIndex.value = index | ||
| 296 | + await loadAudio() | ||
| 297 | + if (audio.value) { | ||
| 298 | + await audio.value.play() | ||
| 299 | + isPlaying.value = true | ||
| 300 | + // 滚动到当前播放的音频 | ||
| 301 | + const playlistItems = document.querySelector('.playlist-items') | ||
| 302 | + const activeItem = playlistItems?.querySelector('.active') | ||
| 303 | + if (playlistItems && activeItem) { | ||
| 304 | + activeItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) | ||
| 305 | + } | ||
| 306 | + } | ||
| 307 | +} | ||
| 308 | + | ||
| 309 | +// 监听播放状态变化,确保当前播放项在可视区域内 | ||
| 310 | +watch([isPlaying, currentIndex], () => { | ||
| 311 | + if (isPlaying.value) { | ||
| 312 | + const playlistItems = document.querySelector('.playlist-items') | ||
| 313 | + const activeItem = playlistItems?.querySelector('.active') | ||
| 314 | + if (playlistItems && activeItem) { | ||
| 315 | + activeItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) | ||
| 316 | + } | ||
| 317 | + } | ||
| 318 | +}) | ||
| 319 | +</script> | ||
| 320 | + | ||
| 321 | +<style scoped> | ||
| 322 | +/* 音频播放器样式变量 */ | ||
| 323 | +.audio-player { | ||
| 324 | + --progress-height: 4px; | ||
| 325 | +} | ||
| 326 | + | ||
| 327 | +/* 进度条样式 */ | ||
| 328 | +.progress-bar { | ||
| 329 | + height: var(--progress-height); | ||
| 330 | + border-radius: var(--progress-height); | ||
| 331 | +} | ||
| 332 | + | ||
| 333 | +.progress-track { | ||
| 334 | + height: var(--progress-height); | ||
| 335 | + border-radius: var(--progress-height); | ||
| 336 | +} | ||
| 337 | + | ||
| 338 | +/* 按钮交互动画 */ | ||
| 339 | +button:not(:disabled) { | ||
| 340 | + transition: all 0.3s ease; | ||
| 341 | +} | ||
| 342 | + | ||
| 343 | +@media (hover: hover) { | ||
| 344 | + button:not(:disabled):hover { | ||
| 345 | + transform: scale(1.1); | ||
| 346 | + } | ||
| 347 | +} | ||
| 348 | + | ||
| 349 | +button.paused .fa-pause { | ||
| 350 | + animation: pulse 1.5s infinite; | ||
| 351 | +} | ||
| 352 | + | ||
| 353 | +button:disabled { | ||
| 354 | + cursor: not-allowed; | ||
| 355 | +} | ||
| 356 | + | ||
| 357 | +/* 暂停按钮动画 */ | ||
| 358 | +@keyframes pulse { | ||
| 359 | + 0% { transform: scale(1); } | ||
| 360 | + 50% { transform: scale(1.1); } | ||
| 361 | + 100% { transform: scale(1); } | ||
| 362 | +} | ||
| 363 | + | ||
| 364 | +/* 播放列表样式 */ | ||
| 365 | +.playlist-items { | ||
| 366 | + border: 1px solid #e5e7eb; | ||
| 367 | + border-top: 0; | ||
| 368 | + border-radius: 0 0 0.375rem 0.375rem; | ||
| 369 | +} | ||
| 370 | + | ||
| 371 | +.active { | ||
| 372 | + background-color: #f3f4f6; | ||
| 373 | + font-weight: 500; | ||
| 374 | + color: #1a1a1a; | ||
| 375 | +} | ||
| 376 | + | ||
| 377 | +/* 移动端样式优化 */ | ||
| 378 | +@media (max-width: 640px) { | ||
| 379 | + .audio-player { | ||
| 380 | + --progress-height: 6px; | ||
| 381 | + } | ||
| 382 | + | ||
| 383 | + input[type="range"] { | ||
| 384 | + height: var(--progress-height); | ||
| 385 | + } | ||
| 386 | +} | ||
| 387 | +</style> |
| 1 | /* | 1 | /* |
| 2 | * @Date: 2025-03-20 20:36:36 | 2 | * @Date: 2025-03-20 20:36:36 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2025-03-24 23:32:16 | 4 | + * @LastEditTime: 2025-04-07 14:24:50 |
| 5 | * @FilePath: /mlaj/src/main.js | 5 | * @FilePath: /mlaj/src/main.js |
| 6 | * @Description: 文件描述 | 6 | * @Description: 文件描述 |
| 7 | */ | 7 | */ |
| ... | @@ -13,10 +13,21 @@ import axios from '@/utils/axios'; | ... | @@ -13,10 +13,21 @@ import axios from '@/utils/axios'; |
| 13 | import 'vant/lib/index.css' | 13 | import 'vant/lib/index.css' |
| 14 | import '@vant/touch-emulator'; | 14 | import '@vant/touch-emulator'; |
| 15 | 15 | ||
| 16 | +/* import the fontawesome core */ | ||
| 17 | +import { library } from '@fortawesome/fontawesome-svg-core' | ||
| 18 | +/* import font awesome icon component */ | ||
| 19 | +import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' | ||
| 20 | +/* import specific icons */ | ||
| 21 | +import { faCirclePause, faCirclePlay, faPlay, faPause, faBackwardStep, faForwardStep, faVolumeUp, faRedo, faRepeat, faList, faChevronDown, faVolumeOff, faXmark } from '@fortawesome/free-solid-svg-icons' | ||
| 22 | + | ||
| 23 | +/* add icons to the library */ | ||
| 24 | +library.add(faCirclePause, faCirclePlay, faPlay, faPause, faBackwardStep, faForwardStep, faVolumeUp, faRedo, faRepeat, faList, faChevronDown, faVolumeOff, faXmark) | ||
| 25 | + | ||
| 16 | const app = createApp(App) | 26 | const app = createApp(App) |
| 17 | // 屏蔽警告信息 | 27 | // 屏蔽警告信息 |
| 18 | app.config.warnHandler = () => null; | 28 | app.config.warnHandler = () => null; |
| 19 | 29 | ||
| 20 | app.config.globalProperties.$http = axios; // 关键语句 | 30 | app.config.globalProperties.$http = axios; // 关键语句 |
| 31 | +app.component('font-awesome-icon', FontAwesomeIcon) | ||
| 21 | app.use(router) | 32 | app.use(router) |
| 22 | app.mount('#app') | 33 | app.mount('#app') | ... | ... |
| ... | @@ -163,6 +163,12 @@ export const routes = [ | ... | @@ -163,6 +163,12 @@ export const routes = [ |
| 163 | meta: { title: '修改密码' }, | 163 | meta: { title: '修改密码' }, |
| 164 | }, | 164 | }, |
| 165 | { | 165 | { |
| 166 | + path: '/profile/settings/audio', | ||
| 167 | + name: 'AudioPlayer', | ||
| 168 | + component: () => import('../views/profile/settings/AudioPlayerPage.vue'), | ||
| 169 | + meta: { title: '音频播放' }, | ||
| 170 | + }, | ||
| 171 | + { | ||
| 166 | path: '/profile/learning-records', | 172 | path: '/profile/learning-records', |
| 167 | name: 'LearningRecords', | 173 | name: 'LearningRecords', |
| 168 | component: () => import('../views/profile/LearningRecordsPage.vue'), | 174 | component: () => import('../views/profile/LearningRecordsPage.vue'), | ... | ... |
src/utils/time.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Date: 2025-04-07 12:41:59 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2025-04-07 12:42:05 | ||
| 5 | + * @FilePath: /mlaj/src/utils/time.js | ||
| 6 | + * @Description: 文件描述 | ||
| 7 | + */ | ||
| 8 | +/** | ||
| 9 | + * 格式化时间戳为 mm:ss 或 hh:mm:ss 格式 | ||
| 10 | + * @param {number} seconds - 总秒数(支持小数) | ||
| 11 | + * @returns {string} 格式化后的时间字符串 | ||
| 12 | + */ | ||
| 13 | +export function formatTime(seconds) { | ||
| 14 | + if (isNaN(seconds) || seconds < 0) return '0:00' | ||
| 15 | + | ||
| 16 | + const hours = Math.floor(seconds / 3600) | ||
| 17 | + seconds %= 3600 | ||
| 18 | + const minutes = Math.floor(seconds / 60) | ||
| 19 | + seconds = Math.floor(seconds % 60) | ||
| 20 | + | ||
| 21 | + const pad = (n) => n.toString().padStart(2, '0') | ||
| 22 | + if (hours > 0) { | ||
| 23 | + return `${hours}:${pad(minutes)}:${pad(seconds)}` | ||
| 24 | + } | ||
| 25 | + return `${minutes}:${pad(seconds)}` | ||
| 26 | +} |
| ... | @@ -64,6 +64,17 @@ | ... | @@ -64,6 +64,17 @@ |
| 64 | <ChevronRightIcon class="w-5 h-5 text-gray-400" /> | 64 | <ChevronRightIcon class="w-5 h-5 text-gray-400" /> |
| 65 | </div> | 65 | </div> |
| 66 | </div> | 66 | </div> |
| 67 | + | ||
| 68 | + <!-- 音频播放 --> | ||
| 69 | + <div class="p-4" @click="router.push('/profile/settings/audio')"> | ||
| 70 | + <div class="flex items-center justify-between"> | ||
| 71 | + <div> | ||
| 72 | + <h3 class="text-base font-medium text-gray-900">音频播放</h3> | ||
| 73 | + <p class="text-sm text-gray-500">播放音频文件</p> | ||
| 74 | + </div> | ||
| 75 | + <ChevronRightIcon class="w-5 h-5 text-gray-400" /> | ||
| 76 | + </div> | ||
| 77 | + </div> | ||
| 67 | </div> | 78 | </div> |
| 68 | </FrostedGlass> | 79 | </FrostedGlass> |
| 69 | </div> | 80 | </div> | ... | ... |
| 1 | +<!-- | ||
| 2 | + * @Date: 2025-03-24 13:04:21 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2025-04-07 14:06:35 | ||
| 5 | + * @FilePath: /mlaj/src/views/profile/settings/AudioPlayerPage.vue | ||
| 6 | + * @Description: 音频播放页面 | ||
| 7 | +--> | ||
| 8 | +<template> | ||
| 9 | + <AppLayout> | ||
| 10 | + <div class="bg-gradient-to-br from-green-50 via-green-100/30 to-blue-50/30 min-h-screen"> | ||
| 11 | + <div class="px-4 py-6"> | ||
| 12 | + <FrostedGlass class="rounded-xl overflow-hidden"> | ||
| 13 | + <AudioPlayer :songs="audioList" /> | ||
| 14 | + </FrostedGlass> | ||
| 15 | + </div> | ||
| 16 | + </div> | ||
| 17 | + </AppLayout> | ||
| 18 | +</template> | ||
| 19 | + | ||
| 20 | +<script setup> | ||
| 21 | +import { ref } from 'vue'; | ||
| 22 | +import AppLayout from '@/components/layout/AppLayout.vue'; | ||
| 23 | +import FrostedGlass from '@/components/ui/FrostedGlass.vue'; | ||
| 24 | +import AudioPlayer from '@/components/ui/AudioPlayer.vue'; | ||
| 25 | + | ||
| 26 | +// 测试音频数据 | ||
| 27 | +const audioList = ref([ | ||
| 28 | + { | ||
| 29 | + id: 1, | ||
| 30 | + title: '示例音频 1', | ||
| 31 | + artist: '演唱者 1', | ||
| 32 | + cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg', | ||
| 33 | + url: 'https://img.tukuppt.com/newpreview_music/09/03/95/5c8af46b01eb138909.mp3' | ||
| 34 | + }, | ||
| 35 | + { | ||
| 36 | + id: 2, | ||
| 37 | + title: '示例音频 2', | ||
| 38 | + artist: '演唱者 2', | ||
| 39 | + cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg', | ||
| 40 | + url: 'https://img.tukuppt.com/newpreview_music/09/03/72/5c8ad96fbf47328809.mp3' | ||
| 41 | + }, | ||
| 42 | + { | ||
| 43 | + id: 3, | ||
| 44 | + title: '示例音频 3', | ||
| 45 | + artist: '演唱者 3', | ||
| 46 | + cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg', | ||
| 47 | + url: 'https://img.tukuppt.com/newpreview_music/09/03/95/5c8af46b01eb138909.mp3' | ||
| 48 | + }, | ||
| 49 | + { | ||
| 50 | + id: 4, | ||
| 51 | + title: '示例音频 4', | ||
| 52 | + artist: '演唱者 4', | ||
| 53 | + cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg', | ||
| 54 | + url: 'https://img.tukuppt.com/newpreview_music/09/03/72/5c8ad96fbf47328809.mp3' | ||
| 55 | + }, | ||
| 56 | + { | ||
| 57 | + id: 5, | ||
| 58 | + title: '示例音频 5', | ||
| 59 | + artist: '演唱者 5', | ||
| 60 | + cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg', | ||
| 61 | + url: 'https://img.tukuppt.com/newpreview_music/09/03/95/5c8af46b01eb138909.mp3' | ||
| 62 | + }, | ||
| 63 | + { | ||
| 64 | + id: 6, | ||
| 65 | + title: '示例音频 6', | ||
| 66 | + artist: '演唱者 6', | ||
| 67 | + cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg', | ||
| 68 | + url: 'https://img.tukuppt.com/newpreview_music/09/03/72/5c8ad96fbf47328809.mp3' | ||
| 69 | + }, | ||
| 70 | + { | ||
| 71 | + id: 7, | ||
| 72 | + title: '示例音频 7', | ||
| 73 | + artist: '演唱者 7', | ||
| 74 | + cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg', | ||
| 75 | + url: 'https://img.tukuppt.com/newpreview_music/09/03/95/5c8af46b01eb138909.mp3' | ||
| 76 | + }, | ||
| 77 | + { | ||
| 78 | + id: 8, | ||
| 79 | + title: '示例音频 8', | ||
| 80 | + artist: '演唱者 8', | ||
| 81 | + cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg', | ||
| 82 | + url: 'https://img.tukuppt.com/newpreview_music/09/03/72/5c8ad96fbf47328809.mp3' | ||
| 83 | + }, | ||
| 84 | +]); | ||
| 85 | +</script> |
| 1 | <!-- | 1 | <!-- |
| 2 | - * @Date: 2025-03-20 19:53:12 | 2 | + * @Date: 2025-03-21 13:12:37 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2025-03-20 23:56:05 | 4 | + * @LastEditTime: 2025-04-07 12:57:28 |
| 5 | - * @FilePath: /mlaj/src/App.vue | 5 | + * @FilePath: /mlaj/src/views/test.vue |
| 6 | * @Description: 文件描述 | 6 | * @Description: 文件描述 |
| 7 | --> | 7 | --> |
| 8 | -<script setup> | ||
| 9 | -const arr = []; | ||
| 10 | -arr.push({ a: '1' }); | ||
| 11 | -arr.push({ a: '2', b: 'random1' }); | ||
| 12 | -arr.push({ a: '3', b: 'random2', c: true }); | ||
| 13 | -arr.push({ a: '4', b: 'random3', d: 123 }); | ||
| 14 | -arr.push({ a: '5', b: 'random4', e: new Date() }); | ||
| 15 | - | ||
| 16 | -const arr2 = arr.map(item => { | ||
| 17 | - return { | ||
| 18 | - ...item, | ||
| 19 | - b: 'random5' | ||
| 20 | - } | ||
| 21 | -}) | ||
| 22 | - | ||
| 23 | -console.warn(arr2); | ||
| 24 | -</script> | ||
| 25 | - | ||
| 26 | -<template> | ||
| 27 | -</template> | ||
| 28 | - | ||
| 29 | -<style> | ||
| 30 | - | ||
| 31 | -</style> | ... | ... |
-
Please register or login to post a comment