Files
BYW-VoiceHub/app/components/UI/AudioPlayer/VolumeControl.vue
T

341 lines
8.2 KiB
Vue

<template>
<div
class="volume-control-container"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@wheel.prevent="handleWheel"
>
<!-- 弹出的音量条 (仅 PC 端 hover 或移动端点击时显示) -->
<Transition name="volume-slide">
<div v-show="showSlider" class="volume-slider-popup">
<div
ref="sliderRef"
class="volume-slider"
@mousedown="startDrag"
@touchstart.prevent="startTouchDrag"
@click="handleSliderClick"
>
<div class="volume-slider-bg">
<div
class="volume-slider-fill"
:style="{ height: `${volumePercentage}%` }"
/>
<div
class="volume-slider-thumb"
:style="{ bottom: `${volumePercentage}%` }"
:class="{ dragging: isDragging }"
/>
</div>
</div>
</div>
</Transition>
<!-- 底部按钮区 -->
<div
class="volume-btn"
:class="{ active: showSlider }"
@click="toggleMuteOrShowSlider"
>
<!-- 显示百分比或图标 -->
<Transition name="fade-icon" mode="out-in">
<span v-if="showSlider" class="volume-text sf-pro">
{{ Math.round(volumePercentage) }}%
</span>
<Icon
v-else
:name="volumeIcon"
:size="20"
:class="{ 'muted-icon': isMuted }"
/>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import Icon from '../Icon.vue'
import { useAudioPlayerControl } from '~/composables/useAudioPlayerControl'
const control = useAudioPlayerControl()
const isMobile = ref(false)
const showSlider = ref(false)
const sliderRef = ref<HTMLElement | null>(null)
const isDragging = ref(false)
const hideTimer = ref<number | null>(null)
const volumePercentage = computed(() => {
return (control.volume.value || 0) * 100
})
const isMuted = computed(() => {
return control.isMuted.value || control.volume.value === 0
})
const volumeIcon = computed(() => {
if (isMuted.value || control.volume.value === 0) return 'volume-mute'
return 'volume'
})
const checkMobile = () => {
if (import.meta.client) {
isMobile.value = window.innerWidth <= 768
}
}
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
document.removeEventListener('click', handleClickOutside)
if (hideTimer.value !== null) {
clearTimeout(hideTimer.value)
}
})
// PC端 Hover 效果
const handleMouseEnter = () => {
if (isMobile.value || isDragging.value) return
if (hideTimer.value !== null) clearTimeout(hideTimer.value)
showSlider.value = true
}
const handleMouseLeave = () => {
if (isMobile.value || isDragging.value) return
hideTimer.value = window.setTimeout(() => {
showSlider.value = false
}, 300)
}
// 移动端点击处理
const toggleMuteOrShowSlider = (e: Event) => {
if (isMobile.value) {
e.stopPropagation()
showSlider.value = !showSlider.value
} else {
// PC端点击直接静音/取消静音
control.toggleMute()
}
}
// 滚轮控制音量
const handleWheel = (e: WheelEvent) => {
if (!showSlider.value && !isMobile.value) return
// 向上滚增加音量,向下滚减小音量
const delta = e.deltaY > 0 ? -0.05 : 0.05
control.setVolume(control.volume.value + delta)
}
// 拖拽控制
const updateVolumeFromEvent = (clientY: number) => {
if (!sliderRef.value) return
const rect = sliderRef.value.getBoundingClientRect()
// 计算相对于底部的百分比
const y = Math.max(0, Math.min(rect.height, rect.bottom - clientY))
const percentage = y / rect.height
control.setVolume(percentage)
}
const handleSliderClick = (e: MouseEvent) => {
updateVolumeFromEvent(e.clientY)
}
const startDrag = (e: MouseEvent) => {
isDragging.value = true
updateVolumeFromEvent(e.clientY)
const onMouseMove = (e: MouseEvent) => {
updateVolumeFromEvent(e.clientY)
}
const onMouseUp = () => {
isDragging.value = false
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
// 如果鼠标已经离开区域,隐藏 slider
if (!isMobile.value) {
hideTimer.value = window.setTimeout(() => {
showSlider.value = false
}, 500)
}
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
const startTouchDrag = (e: TouchEvent) => {
isDragging.value = true
updateVolumeFromEvent(e.touches[0].clientY)
const onTouchMove = (e: TouchEvent) => {
updateVolumeFromEvent(e.touches[0].clientY)
}
const onTouchEnd = () => {
isDragging.value = false
document.removeEventListener('touchmove', onTouchMove)
document.removeEventListener('touchend', onTouchEnd)
}
document.addEventListener('touchmove', onTouchMove, { passive: false })
document.addEventListener('touchend', onTouchEnd)
}
const handleClickOutside = (e: Event) => {
if (isMobile.value && showSlider.value) {
const target = e.target as HTMLElement
if (!target.closest('.volume-control-container')) {
showSlider.value = false
}
}
}
</script>
<style scoped>
.volume-control-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 39px;
height: 39px;
z-index: 100;
}
/* 底部按钮 */
.volume-btn {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary-60);
cursor: pointer;
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
border-radius: 50%;
background: transparent;
user-select: none;
}
.volume-btn:hover {
color: var(--text-primary);
transform: scale(1.1);
}
.volume-btn.active {
color: var(--text-primary);
background: var(--overlay-10);
border-radius: 20px;
}
.muted-icon {
color: var(--volume-control-muted-color);
}
.volume-text {
font-size: 12px;
font-weight: 600;
letter-spacing: -0.5px;
}
/* 图标/文本切换动画 */
.fade-icon-enter-active,
.fade-icon-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.fade-icon-enter-from,
.fade-icon-leave-to {
opacity: 0;
transform: scale(0.8);
}
/* 弹出层 */
.volume-slider-popup {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
width: 36px;
height: 120px;
background: linear-gradient(135deg, var(--overlay-15), var(--overlay-5)),
var(--volume-control-panel-bg);
backdrop-filter: blur(20px) saturate(1.8);
-webkit-backdrop-filter: blur(20px) saturate(1.8);
border: 1px solid var(--overlay-10);
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10px 30px var(--mask-30), inset 0 1px 0 var(--overlay-20);
padding: 12px 0;
}
/* 弹出动画 */
.volume-slide-enter-active {
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
transform-origin: bottom center;
}
.volume-slide-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transform-origin: bottom center;
}
.volume-slide-enter-from,
.volume-slide-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(10px) scale(0.8);
}
/* 滑动条容器 */
.volume-slider {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
cursor: pointer;
touch-action: none;
}
.volume-slider-bg {
position: relative;
width: 6px;
height: 100%;
background: var(--overlay-20);
border-radius: 3px;
}
.volume-slider-fill {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
background: linear-gradient(to top, var(--color-accent-light), var(--color-cyan));
border-radius: 3px;
transition: height 0.1s linear;
}
.volume-slider-thumb {
position: absolute;
left: 50%;
transform: translate(-50%, 50%);
width: 14px;
height: 14px;
background: var(--text-primary);
border-radius: 50%;
box-shadow: 0 2px 6px var(--mask-30);
transition: transform 0.2s ease, box-shadow 0.2s ease;
pointer-events: none;
}
.volume-slider-thumb.dragging,
.volume-slider:hover .volume-slider-thumb {
transform: translate(-50%, 50%) scale(1.2);
box-shadow: 0 4px 10px var(--mask-40);
}
</style>