feat(player): add enhanced volume button with smooth fade control

This commit is contained in:
wz219 2025-10-09 19:46:51 +13:00
parent 6e7ba0e51c
commit 7ed2b35d66

View file

@ -0,0 +1,284 @@
<template>
<div class="relative">
<!-- 触发按钮显示图标和进度 -->
<button class="appbar-btn" title="Volume Fade" aria-label="Volume Fade" @click="open = !open">
<span class="material-symbols text-2xl"> volume_down_alt </span>
<span v-if="running" class="ml-1 text-sm font-medium">{{ timeLeftLabel }}</span>
</button>
<!-- 弹出面板 -->
<div v-if="open" class="absolute right-0 mt-2 w-72 rounded-md border shadow-lg z-50" :style="panelStyle">
<div class="px-3 py-2 text-sm font-semibold">Volume Fade</div>
<div class="px-3 pb-3 space-y-3">
<div class="grid grid-cols-2 gap-2">
<div class="field">
<label>Duration (min)</label>
<input type="number" min="1" class="input" v-model.number="minutes" />
</div>
<div class="field">
<label>Target Volume</label>
<input type="number" min="0" max="1" step="0.05" class="input" v-model.number="targetVolume" />
</div>
</div>
<div class="field">
<label>Curve</label>
<select class="input" v-model="curve">
<option value="linear">Linear</option>
<option value="easeIn">Ease-in</option>
<option value="easeOut">Ease-out</option>
<option value="log">Logarithmic</option>
</select>
</div>
<div class="flex items-center justify-between">
<label class="inline-flex items-center gap-2 text-sm">
<input type="checkbox" v-model="pauseOnEnd" />
Pause at the end
</label>
<span class="text-sm opacity-80">Current: {{ currentVolume.toFixed(2) }}</span>
</div>
<!-- 进度条 -->
<div v-if="running" class="h-2 w-full bg-[color:var(--border)] rounded overflow-hidden">
<div class="h-full bg-[color:var(--accent)]" :style="{ width: (progress * 100).toFixed(1) + '%' }" />
</div>
<!-- 启动/停止按钮 -->
<div class="flex gap-2">
<button v-if="!running" class="btn-primary flex-1" @click="startFade">Start</button>
<button v-else class="btn-danger flex-1" @click="stopFade(true)">Stop</button>
</div>
</div>
</div>
</div>
</template>
<script>
/**
* 接入方式二选一已在代码里给了开关
* A) 使用 Vuex 播放模块
* - 获取音量this.$store.getters['player/getVolume'] state
* - 设置音量this.$store.dispatch('player/setVolume', vol)
* - 暂停播放this.$store.dispatch('player/pause')
*
* B) 使用全局事件总线eventBus
* - 设置音量this.$eventBus.$emit('player:set-volume', vol)
* - 暂停播放this.$eventBus.$emit('player:pause')
*
* 你只需要把TODO 接入点那里换成你项目里已有的函数/事件名即可
*/
export default {
name: 'VolumeFade',
data() {
return {
open: false,
minutes: 5, //
targetVolume: 0, // 0~1
curve: 'linear', // 线linear/easeIn/easeOut/log
pauseOnEnd: true, //
running: false,
//
startTs: 0,
endTs: 0,
startVolume: 1,
rafId: null,
progress: 0,
currentVolume: 1
}
},
computed: {
panelStyle() {
return {
background: 'var(--panel)',
borderColor: 'var(--border)',
color: 'var(--text)'
}
},
timeLeftLabel() {
if (!this.running) return ''
const left = Math.max(0, this.endTs - Date.now())
const mm = Math.floor(left / 60000)
const ss = Math.floor((left % 60000) / 1000)
return `${mm}:${ss.toString().padStart(2, '0')}`
}
},
methods: {
// === TODO/ + ===
getVolume() {
// A) Vuex
// return this.$store.getters['player/getVolume']
// B) getter audio el
// return this.$refs.audio?.volume ?? 1
// C) eventBus
//
const v = Number(localStorage.getItem('playerVolume'))
return Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : this.currentVolume
},
setVolume(vol) {
const clamped = Math.min(1, Math.max(0, vol))
// A) Vuex
// this.$store.dispatch('player/setVolume', clamped)
// B) eventBus
this.$eventBus && this.$eventBus.$emit('player:set-volume', clamped)
// UI
this.currentVolume = clamped
// 便 getVolume
localStorage.setItem('playerVolume', String(clamped))
},
pause() {
// A) Vuex
// this.$store.dispatch('player/pause')
// B) eventBus
this.$eventBus && this.$eventBus.$emit('player:pause')
},
// === ===
startFade() {
//
const durationMs = Math.max(5 * 1000, (this.minutes || 0) * 60 * 1000) // 5
this.startVolume = this.getVolume()
this.currentVolume = this.startVolume
// 0~1
this.targetVolume = Math.min(1, Math.max(0, this.targetVolume))
//
if (Math.abs(this.startVolume - this.targetVolume) < 0.001) {
this.$toast && this.$toast.info('Already at target volume')
return
}
this.startTs = Date.now()
this.endTs = this.startTs + durationMs
this.running = true
this.progress = 0
this.tick()
this.open = false
this.$toast && this.$toast.success('Volume fade started')
},
stopFade(notify = false) {
this.running = false
this.progress = 0
if (this.rafId) {
cancelAnimationFrame(this.rafId)
this.rafId = null
}
if (notify && this.$toast) this.$toast.info('Volume fade stopped')
},
tick() {
if (!this.running) return
const now = Date.now()
const t = Math.min(1, (now - this.startTs) / (this.endTs - this.startTs)) // 0~1
this.progress = t
const eased = this.applyCurve(t, this.curve)
const next = this.lerp(this.startVolume, this.targetVolume, eased)
this.setVolume(next)
if (now >= this.endTs) {
this.setVolume(this.targetVolume)
this.stopFade(false)
if (this.pauseOnEnd) this.pause()
this.$toast && this.$toast.success('Volume fade completed')
return
}
this.rafId = requestAnimationFrame(this.tick)
},
// 线
lerp(a, b, t) {
return a + (b - a) * t
},
// 线
applyCurve(t, mode) {
switch (mode) {
case 'easeIn':
return t * t
case 'easeOut':
return 1 - Math.pow(1 - t, 2)
case 'log':
return Math.log2(1 + t) / Math.log2(2) // 0->1
default:
return t // linear
}
}
},
mounted() {
// eventBus 广
// this.$eventBus.$on('player:volume', v => {
// this.currentVolume = v
// localStorage.setItem('playerVolume', String(v))
// })
//
this.currentVolume = this.getVolume()
},
beforeDestroy() {
if (this.rafId) cancelAnimationFrame(this.rafId)
// this.$eventBus && this.$eventBus.$off('player:volume')
}
}
</script>
<style scoped>
.appbar-btn {
width: 2.25rem;
height: 2.25rem;
min-width: 2.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0 0.25rem;
border-radius: 0.375rem;
color: var(--text);
transition: background-color 0.18s ease, transform 0.06s ease;
}
.appbar-btn:hover {
background: rgba(255, 255, 255, 0.06);
}
html[data-theme='light'] .appbar-btn:hover {
background: rgba(0, 0, 0, 0.06);
}
.appbar-btn:active {
transform: scale(0.98);
}
.field label {
display: block;
font-size: 0.75rem;
opacity: 0.8;
margin-bottom: 0.25rem;
}
.input {
width: 100%;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
border-radius: 0.5rem;
padding: 0.35rem 0.5rem;
}
.btn-primary {
background-image: linear-gradient(135deg, var(--accent), var(--accent-2));
color: #101010;
border: none;
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
}
.btn-danger {
background: #dc2626;
color: #fff;
border: none;
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
}
</style>