audiobookshelf/client/components/widgets/VolumeFade.vue

284 lines
8.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>