# 客製化音樂播放器 (Nuxt3)
## 📌 概述
一個基於 Nuxt3 開發的客製化音樂播放器,支援自訂版面配置、排版與配色,提供完整的播放控制功能。
**[線上 Demo](https://codesandbox.io/p/sandbox/holy-cloud-89296t?file=%2Fapp.vue%3A34%2C98)**
---
## 🚀 功能特色
- 播放/暫停控制
- 快退 10 秒 (支援長按連續快退)
- 快進 10 秒 (支援長按連續快進)
- 進度條拖曳控制
- 音量調節
- 靜音開關
- 音檔下載
- 時間顯示 (支援 mm:ss 與 HH:mm:ss 格式)
---
## 📦 安裝依賴
```bash
npm i dayjs
```
---
## 💻 程式碼實作
### HTML 模板
```vue=
<template>
<audio ref="audioBox" :src="audioUrl"></audio>
<div class="audio_box">
<!-- 快退按鈕 -->
<button
class="rewind"
@mousedown="setTimer('rewind')"
@mouseup="clearTimer()"
>
<img src="/icon/rewind_ic.svg" alt="快退">
</button>
<!-- 播放/暫停按鈕 -->
<button
:class="{ 'pause': audioPaly, 'play': !audioPaly }"
@click="audioPaly = !audioPaly"
>
<div class="play_icon"></div>
</button>
<!-- 快進按鈕 -->
<button
class="fast_forward"
@mousedown="setTimer('fast_forward')"
@mouseup="clearTimer()"
>
<img src="/icon/fast_forward_ic.svg" alt="快進">
</button>
<!-- 時間顯示 -->
<div>
<span>{{ formatDate(currentTime) }}</span> /
<span>{{ formatDate(totalTime) }}</span>
</div>
<!-- 進度條 -->
<div class="progressBar_sensingBlock">
<input
type="range"
min="0"
:max="totalTime"
v-model="currentProgress"
step="0.1"
@mousedown="updateProgressData()"
@mouseup="updateCurrentTime()"
>
</div>
<!-- 靜音按鈕 -->
<div
class="trumpet_block"
:class="{ 'noSound': noSound }"
@click="audioBox.muted = !audioBox.muted; noSound = !noSound"
>
<img src="/icon/trumpet_ic.svg" alt="音量">
</div>
<!-- 音量控制 -->
<div class="volume_box">
<input
type="range"
min="0"
max="1"
step="0.1"
v-model="volume"
>
</div>
<!-- 下載按鈕 -->
<a
class="audio_download"
:href="audioUrl"
:download="audioName"
>
<img src="/icon/download_15_ic.svg" alt="下載">
</a>
</div>
</template>
```
### JavaScript (Composition API)
```javascript=
<script setup>
import { ref, watch, onMounted } from 'vue'
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import utc from 'dayjs/plugin/utc'
// 啟用 Day.js 插件
dayjs.extend(duration)
dayjs.extend(utc)
// 響應式數據
const audioUrl = ref('')
const audioName = ref('')
const audioBox = ref()
const totalTime = ref(0)
const currentTime = ref(0)
const audioPaly = ref(false)
const currentProgress = ref(0)
const noSound = ref(false)
const volume = ref(1)
const progressData = ref(0)
let rewindTimer = null
// 時間格式化函數
const formatDate = (seconds) => {
const dur = dayjs.duration(seconds, 'seconds')
const format = Math.trunc(seconds) >= 3600 ? 'HH:mm:ss' : 'mm:ss'
return dayjs.utc(dur.asMilliseconds()).format(format)
}
// 初始化音頻設定
const setAudio = () => {
totalTime.value = Math.ceil(audioBox.value.duration)
currentTime.value = Math.ceil(audioBox.value.currentTime)
// 音頻數據加載完成
audioBox.value.addEventListener('loadedmetadata', () => {
totalTime.value = Math.ceil(audioBox.value.duration)
currentTime.value = Math.ceil(audioBox.value.currentTime)
})
// 播放時間更新
audioBox.value.addEventListener('timeupdate', () => {
currentTime.value = Math.ceil(audioBox.value.currentTime)
currentProgress.value = currentTime.value
// 播放結束重置狀態
if (audioBox.value.currentTime === audioBox.value.duration) {
audioPaly.value = false
}
})
}
// 監聽播放狀態
watch(audioPaly, (newValue) => {
newValue ? audioBox.value.play() : audioBox.value.pause()
})
// 快退功能
const rewind = () => {
if (currentTime.value > 10) {
audioBox.value.currentTime -= 10
} else {
audioBox.value.currentTime = 0
}
}
// 快進功能
const fast_forward = () => {
if (totalTime.value - currentTime.value > 10) {
audioBox.value.currentTime = currentTime.value + 10
} else {
audioBox.value.currentTime = totalTime.value
}
}
// 設定計時器 (長按功能)
const setTimer = (action) => {
rewindTimer = setInterval(() => {
action === 'rewind' ? rewind() : fast_forward()
}, 500)
}
// 清除計時器
const clearTimer = () => {
clearInterval(rewindTimer)
audioPaly.value = true
}
// 進度條按下時暫停播放
const updateProgressData = () => {
audioPaly.value = false
audioBox.value.pause()
watch(currentProgress, (newValue) => {
progressData.value = newValue
currentTime.value = newValue
})
}
// 進度條釋放時更新播放位置
const updateCurrentTime = () => {
audioBox.value.currentTime = progressData.value
audioPaly.value = true
audioBox.value.play()
}
// 監聽音量變化
watch(volume, (newValue) => {
audioBox.value.volume = newValue
noSound.value = newValue === 0
})
onMounted(() => {
setAudio()
})
</script>
```
### SCSS 樣式
```scss
.audio_box {
width: 100%;
display: flex;
align-items: center;
padding: 10px 45px;
border-radius: 20px;
background: #000;
box-sizing: border-box;
column-gap: 20px;
// 播放按鈕
.play {
position: relative;
border: none;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
width: 27px;
height: 27px;
.play_icon {
display: block;
width: 0;
height: 0;
border-left: 12px solid #fff;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
cursor: pointer;
}
}
// 暫停按鈕
.pause {
position: relative;
border: none;
background: transparent;
.play_icon {
display: flex;
cursor: pointer;
&::before,
&::after {
content: '';
display: block;
width: 3px;
height: 15px;
background: #fff;
border-radius: 1px;
margin-left: 3px;
}
}
}
// 快進/快退按鈕
.rewind,
.fast_forward {
cursor: pointer;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: #fff;
display: flex;
justify-content: center;
align-items: center;
img {
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
}
}
// 進度條
.progressBar_sensingBlock {
flex-grow: 1;
input[type="range"] {
width: 100%;
accent-color: $footer_bg_color;
}
}
// 靜音按鈕
.trumpet_block {
width: 32px;
height: 32px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
position: relative;
&::before {
content: '';
position: absolute;
display: block;
width: 0;
height: 2px;
background: red;
left: 50%;
top: 50%;
transform: translate(-50%, -50%) rotate(50deg);
transition: 0.5s;
}
&.noSound::before {
width: 25px;
}
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
// 音量控制
.volume_box {
width: 60px;
input[type="range"] {
width: 100%;
accent-color: #fff;
}
}
// 下載按鈕
.audio_download {
display: block;
width: 33px;
height: 33px;
background: #fff;
border-radius: 50%;
text-align: center;
img {
width: 19px;
height: 19px;
}
}
}
```
---
## 🎨 Icon 資源
### rewind_ic.svg (快退圖示)
```svg
<svg width="17" height="14" viewBox="0 0 17 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.49915 5.7804L14.5204 0.264481C15.1619 -0.323156 16.1959 0.131904 16.1959 1.00185V12.9092C16.1959 13.7791 15.1619 14.2342 14.5204 13.6465L8.49914 8.13062V12.9092C8.49914 13.7791 7.46514 14.2342 6.82366 13.6465L0.324528 7.69288C-0.108158 7.29651 -0.10816 6.6145 0.324527 6.21813L6.82366 0.264481C7.46514 -0.323156 8.49915 0.131904 8.49915 1.00185L8.49915 5.7804Z" fill="white"/>
</svg>
```
### fast_forward_ic.svg (快進圖示)
```svg
<svg width="17" height="14" viewBox="0 0 17 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.69678 8.13073L1.67549 13.6467C1.03401 14.2343 0 13.7792 0 12.9093L0 1.00197C0 0.13202 1.03401 -0.32304 1.67549 0.264599L7.69678 5.78052V1.00197C7.69678 0.13202 8.73078 -0.32304 9.37226 0.264599L15.8714 6.21825C16.3041 6.61462 16.3041 7.29663 15.8714 7.693L9.37226 13.6467C8.73079 14.2343 7.69678 13.7792 7.69678 12.9093V8.13073Z" fill="white"/>
</svg>
```
### trumpet_ic.svg (音量圖示)
```svg
<svg width="17" height="15" viewBox="0 0 17 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect y="1.875" width="17" height="13.125" rx="3" fill="#1986C9"/>
<rect x="3.54163" y="5.625" width="9.91667" height="6.875" fill="#CAE3F1"/>
<rect x="10.625" y="8.75" width="2.83333" height="0.625" fill="#1986C9"/>
<rect x="7.08337" y="8.75" width="2.83333" height="0.625" fill="#1986C9"/>
<rect x="3.54163" y="8.75" width="2.83333" height="0.625" fill="#1986C9"/>
<path d="M6.375 5.625H7.08333V12.5H6.375V5.625Z" fill="#1986C9"/>
<rect x="9.91663" y="5.625" width="0.708333" height="6.875" fill="#1986C9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.54163 0C3.98934 0 3.54163 0.447715 3.54163 1V1.5C3.54163 1.63261 3.56744 1.7592 3.61432 1.875H5.59394C5.64081 1.7592 5.66663 1.63261 5.66663 1.5V1C5.66663 0.447715 5.21891 0 4.66663 0H4.54163Z" fill="#1986C9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.3334 0C11.7811 0 11.3334 0.447715 11.3334 1V1.5C11.3334 1.63261 11.3592 1.7592 11.4061 1.875H13.3857C13.4326 1.7592 13.4584 1.63261 13.4584 1.5V1C13.4584 0.447715 13.0107 0 12.4584 0H12.3334Z" fill="#1986C9"/>
</svg>
```
### download_15_ic.svg (下載圖示)
```svg
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.39489 8.34693C3.45742 8.34693 2.5512 8.34693 1.61373 8.34693C1.0825 8.34693 0.645012 8.0369 0.488767 7.58593C0.457518 7.47319 0.42627 7.38864 0.42627 7.2759C0.42627 6.90949 0.42627 6.54309 0.42627 6.17668C0.42627 5.97939 0.582514 5.81028 0.770008 5.78209C0.988751 5.75391 1.17624 5.81028 1.26999 6.00757C1.30124 6.06394 1.30124 6.1485 1.30124 6.23305C1.30124 6.54309 1.30124 6.85312 1.30124 7.16316C1.30124 7.41682 1.42624 7.52956 1.73873 7.52956C3.51992 7.52956 5.33235 7.52956 7.11354 7.52956C7.39479 7.52956 7.55103 7.41682 7.55103 7.16316C7.55103 6.85312 7.55103 6.5149 7.55103 6.20487C7.55103 5.92302 7.70728 5.75391 7.98852 5.75391C8.23851 5.75391 8.426 5.92302 8.426 6.17668C8.426 6.5149 8.426 6.88131 8.426 7.21953C8.39475 7.8396 7.86352 8.31875 7.20729 8.31875C6.23857 8.34693 5.33235 8.34693 4.39489 8.34693Z" fill="white"/>
<path d="M4.92642 4.8246C5.17642 4.59912 5.39516 4.37364 5.64515 4.17635C5.70765 4.11998 5.8014 4.06361 5.89514 4.03542C6.08264 3.97905 6.27013 4.06361 6.36388 4.20453C6.45762 4.34546 6.45762 4.54275 6.30138 4.68368C5.95764 4.99371 5.6139 5.30375 5.27016 5.61378C5.11392 5.75471 4.92642 5.92382 4.77018 6.06474C4.55144 6.26204 4.30145 6.26204 4.0827 6.06474C3.58272 5.61378 3.05149 5.16282 2.5515 4.68368C2.36401 4.48638 2.33276 4.2609 2.52025 4.09179C2.70775 3.92268 2.95774 3.95087 3.17648 4.11998C3.39523 4.28909 3.58272 4.48638 3.77021 4.65549C3.80146 4.68368 3.86396 4.74005 3.95771 4.79642C3.95771 4.68368 3.95771 4.62731 3.95771 4.57094C3.95771 3.38716 3.95771 2.17521 3.95771 0.991435C3.95771 0.90688 3.95771 0.822325 3.95771 0.73777C4.0202 0.56866 4.2077 0.427734 4.42644 0.427734C4.64518 0.427734 4.83268 0.56866 4.86393 0.765955C4.86393 0.85051 4.86393 0.935065 4.86393 1.01962C4.86393 2.23158 4.86393 3.41535 4.86393 4.62731C4.86393 4.68368 4.86393 4.71186 4.86393 4.76823C4.89518 4.79642 4.89518 4.79642 4.92642 4.8246Z" fill="white"/>
</svg>
```
---
## 🐛 Bug 修正紀錄
### 進度條導致的播放問題
**問題描述:** 原本直接監聽進度條變化會導致拖曳時音頻不斷跳轉
**原始錯誤寫法:**
```javascript
// ❌ 問題版本
watch(currentProgress, (newValue) => {
audioBox.value.currentTime = newValue
})
```
**修正後的正確寫法:**
```javascript
// ✅ 修正版本
const progressData = ref(0)
// 按下時暫停並記錄位置
const updateProgressData = () => {
audioPaly.value = false
audioBox.value.pause()
watch(currentProgress, (newValue) => {
progressData.value = newValue
currentTime.value = newValue
})
}
// 釋放時更新播放位置並繼續播放
const updateCurrentTime = () => {
audioBox.value.currentTime = progressData.value
audioPaly.value = true
audioBox.value.play()
}
```
**修正原理:** 將進度條的拖曳操作分為「按下」和「釋放」兩個階段處理,避免拖曳過程中頻繁更新播放位置。