Files
QZMusic_PC-pre/src/renderer/src/components/player/BouncingSlider.vue
lqtmcstudio 85862af686 fix: 播放页&功能&界面优化
- 播放页UI(全)
- 音量控制优化
- 歌词组件
- 各种控件组件
- 图标打包
- 布局优化
2026-02-09 11:38:47 +08:00

224 lines
5.2 KiB
Vue

<template>
<div
class="bouncing-slider"
ref="containerRef"
@mousedown="handlePanStart"
@touchstart="handlePanStart"
@mouseenter="handleHoverStart"
@mouseleave="handleHoverEnd"
:style="{
transform: `translateX(${bounceX}px)`
}"
>
<div class="inner" :style="{ clipPath: clipPath }">
<div
class="thumb"
:style="{
transform: `scaleX(${progress})`
}"
></div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue';
const props = defineProps({
value: {
type: Number,
required: true
},
min: {
type: Number,
default: 0
},
max: {
type: Number,
default: 100
},
isPlaying: {
type: Boolean,
default: false
},
changeOnDrag: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:value', 'drag-start', 'drag-end', 'seeking']);
const containerRef = ref<HTMLElement | null>(null);
const isHovering = ref(false);
const isDragging = ref(false);
const localValue = ref(props.value);
const bounceX = ref(0);
const THROTTLE_MS = 20;
let lastEmitTime = 0;
const MAX_HEIGHT = 20;
const MIN_HEIGHT = 8;
const INITIAL_INSET = (MAX_HEIGHT - MIN_HEIGHT) / 2;
const MAX_BOUNCE_DISTANCE = 12;
const inset = ref(INITIAL_INSET);
const clipPath = computed(() => {
return `inset(${inset.value}px 0px round 100px)`;
});
const progress = computed(() => {
const range = props.max - props.min;
if (range === 0) return 0;
return Math.max(0, Math.min(1, (localValue.value - props.min) / range));
});
// Sync localValue with props.value when not dragging
watch(() => props.value, (newVal) => {
if (!isDragging.value) {
localValue.value = newVal;
}
});
const expand = () => {
inset.value = 0;
};
const collapse = () => {
inset.value = INITIAL_INSET;
};
const handleHoverStart = () => {
isHovering.value = true;
if (!isDragging.value) {
expand();
}
};
const handleHoverEnd = () => {
isHovering.value = false;
if (!isDragging.value) {
collapse();
}
};
const handlePanStart = (event: MouseEvent | TouchEvent) => {
isDragging.value = true;
expand();
emit('drag-start');
emit('seeking', true);
// Initial calculation
calculateValue(event);
window.addEventListener('mousemove', handlePan);
window.addEventListener('touchmove', handlePan);
window.addEventListener('mouseup', handlePanEnd);
window.addEventListener('touchend', handlePanEnd);
};
const calculateValue = (event: MouseEvent | TouchEvent) => {
if (!containerRef.value) return;
const rect = containerRef.value.getBoundingClientRect();
const clientX = 'touches' in event ? event.touches[0].clientX : (event as MouseEvent).clientX;
const relPos = (clientX - rect.left) / rect.width;
// Bounce effect
if (relPos < 0) {
bounceX.value = Math.tanh(relPos * 2) * MAX_BOUNCE_DISTANCE;
} else if (relPos > 1) {
bounceX.value = Math.tanh((relPos - 1) * 2) * MAX_BOUNCE_DISTANCE;
} else {
bounceX.value = 0;
}
const clampedPos = Math.max(0, Math.min(1, relPos));
const newValue = props.min + clampedPos * (props.max - props.min);
localValue.value = newValue;
if (props.changeOnDrag) {
const now = Date.now();
if (now - lastEmitTime >= THROTTLE_MS) {
lastEmitTime = now;
emit('update:value', newValue);
}
}
};
const handlePan = (event: Event) => {
// MouseEvent or TouchEvent
calculateValue(event as MouseEvent | TouchEvent);
};
const handlePanEnd = () => {
isDragging.value = false;
bounceX.value = 0;
if (isHovering.value) {
expand();
} else {
collapse();
}
emit('seeking', false);
emit('drag-end');
// Commit value
emit('update:value', localValue.value);
window.removeEventListener('mousemove', handlePan);
window.removeEventListener('touchmove', handlePan);
window.removeEventListener('mouseup', handlePanEnd);
window.removeEventListener('touchend', handlePanEnd);
};
onUnmounted(() => {
window.removeEventListener('mousemove', handlePan);
window.removeEventListener('touchmove', handlePan);
window.removeEventListener('mouseup', handlePanEnd);
window.removeEventListener('touchend', handlePanEnd);
});
</script>
<style scoped>
.bouncing-slider {
touch-action: none;
cursor: pointer;
display: flex;
justify-content: stretch;
align-items: center;
min-height: 24px;
width: 100%; /* Ensure it takes full width */
transform: translateZ(0);
/* Transition for bounce effect on container */
transition: transform 0.1s linear;
}
.inner {
flex: 1;
width: 100%;
height: 20px;
background-color: #ffffff26;
transition: clip-path 0.3s cubic-bezier(0.25, 0.8, 0.5, 1);
overflow: hidden; /* Needed for clip-path visualization in some contexts/fallbacks */
}
.thumb {
width: 100%;
height: 100%;
background-color: white;
opacity: 0.4;
transform-origin: left center;
transition: transform 0.1s linear; /* Smooth visual update during drag/play */
}
.bouncing-slider > svg {
opacity: 0.5;
}
</style>