音乐模块调整
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 通用拖拽进度条。
|
||||
*
|
||||
* 关键行为:拖动过程中**不**向外提交,只更新本地预览值;
|
||||
* 指针抬起(或键盘操作)时才 emit `seek`。
|
||||
* 这样流式音频不会因为拖动而反复中断并重建 Range 请求。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 当前进度 0~1 */
|
||||
value: number
|
||||
/** 已缓冲比例 0~1 */
|
||||
buffered?: number
|
||||
/** 总秒数:>0 时显示两侧时间与拖动气泡 */
|
||||
durationSec?: number
|
||||
/** 紧凑模式(音量条):细轨道、无时间、无气泡 */
|
||||
compact?: boolean
|
||||
disabled?: boolean
|
||||
/** 无障碍标签 */
|
||||
label?: string
|
||||
}>(),
|
||||
{ buffered: 0, durationSec: 0, compact: false, disabled: false, label: '进度' }
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ seek: [ratio: number] }>()
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const dragging = ref(false)
|
||||
const dragRatio = ref(0)
|
||||
const hoverRatio = ref<number | null>(null)
|
||||
|
||||
function clamp(v: number) {
|
||||
return Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0))
|
||||
}
|
||||
function pct(v: number) {
|
||||
return `${clamp(v) * 100}%`
|
||||
}
|
||||
const activeRatio = computed(() => (dragging.value ? dragRatio.value : clamp(props.value)))
|
||||
const bubbleRatio = computed(() => (dragging.value ? dragRatio.value : hoverRatio.value))
|
||||
const showTime = computed(() => !props.compact && props.durationSec > 0)
|
||||
|
||||
function ratioFromClientX(clientX: number) {
|
||||
const el = rootRef.value
|
||||
if (!el) return 0
|
||||
const r = el.getBoundingClientRect()
|
||||
if (r.width <= 0) return 0
|
||||
return clamp((clientX - r.left) / r.width)
|
||||
}
|
||||
|
||||
function fmt(sec: number) {
|
||||
if (!Number.isFinite(sec) || sec < 0) return '--:--'
|
||||
const s = Math.floor(sec)
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
const r = s % 60
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}`
|
||||
return `${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function onDown(e: PointerEvent) {
|
||||
if (props.disabled) return
|
||||
const el = e.currentTarget as HTMLElement
|
||||
dragging.value = true
|
||||
dragRatio.value = ratioFromClientX(e.clientX)
|
||||
try {
|
||||
el.focus({ preventScroll: true })
|
||||
el.setPointerCapture(e.pointerId)
|
||||
} catch {
|
||||
/* 部分环境不支持指针捕获/聚焦,降级为普通拖动 */
|
||||
}
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function onMove(e: PointerEvent) {
|
||||
const r = ratioFromClientX(e.clientX)
|
||||
hoverRatio.value = r
|
||||
if (dragging.value) dragRatio.value = r
|
||||
}
|
||||
|
||||
function onUp(e: PointerEvent) {
|
||||
if (!dragging.value) return
|
||||
dragging.value = false
|
||||
dragRatio.value = ratioFromClientX(e.clientX)
|
||||
emit('seek', dragRatio.value)
|
||||
}
|
||||
|
||||
function onLeave() {
|
||||
if (!dragging.value) hoverRatio.value = null
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (props.disabled) return
|
||||
const step = e.shiftKey ? 0.1 : 0.02
|
||||
let next: number | null = null
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = clamp(props.value + step)
|
||||
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = clamp(props.value - step)
|
||||
else if (e.key === 'Home') next = 0
|
||||
else if (e.key === 'End') next = 1
|
||||
if (next !== null) {
|
||||
emit('seek', next)
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="scrub"
|
||||
:class="{ 'is-compact': compact, 'is-dragging': dragging, 'is-disabled': disabled }"
|
||||
>
|
||||
<span v-if="showTime" class="scrub-time">{{ fmt(activeRatio * durationSec) }}</span>
|
||||
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="scrub-track"
|
||||
role="slider"
|
||||
:tabindex="disabled ? -1 : 0"
|
||||
:aria-label="label"
|
||||
:aria-valuenow="Math.round(activeRatio * 100)"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-disabled="disabled"
|
||||
@pointerdown="onDown"
|
||||
@pointermove="onMove"
|
||||
@pointerup="onUp"
|
||||
@pointercancel="onUp"
|
||||
@pointerleave="onLeave"
|
||||
@keydown="onKey"
|
||||
>
|
||||
<span class="scrub-buffered" :style="{ width: pct(buffered) }" />
|
||||
<span class="scrub-played" :style="{ width: pct(activeRatio) }" />
|
||||
<span class="scrub-thumb" :style="{ left: pct(activeRatio) }" />
|
||||
<span
|
||||
v-if="showTime && bubbleRatio !== null"
|
||||
class="scrub-bubble"
|
||||
:style="{ left: pct(bubbleRatio) }"
|
||||
>
|
||||
{{ fmt(bubbleRatio * durationSec) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span v-if="showTime" class="scrub-time">{{ fmt(durationSec) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scrub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scrub-time {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex: 0 0 auto;
|
||||
min-width: 38px;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
.scrub-time:first-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.scrub-track {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 视觉轨道(高 4px,hover / 拖动时增到 7px) */
|
||||
.scrub-track::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
transition: height 0.15s ease;
|
||||
}
|
||||
.scrub:hover .scrub-track::before,
|
||||
.scrub.is-dragging .scrub-track::before {
|
||||
height: 7px;
|
||||
}
|
||||
.scrub-track:focus-visible::before {
|
||||
box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring) 45%, transparent);
|
||||
}
|
||||
|
||||
.scrub-buffered,
|
||||
.scrub-played {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
transition: height 0.15s ease;
|
||||
}
|
||||
.scrub:hover .scrub-buffered,
|
||||
.scrub.is-dragging .scrub-buffered,
|
||||
.scrub:hover .scrub-played,
|
||||
.scrub.is-dragging .scrub-played {
|
||||
height: 7px;
|
||||
}
|
||||
.scrub-buffered {
|
||||
background: color-mix(in oklab, var(--muted-foreground) 28%, transparent);
|
||||
}
|
||||
.scrub-played {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.scrub-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: -6px 0 0 -6px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
box-shadow: 0 0 0 2px var(--background);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.scrub:hover .scrub-thumb,
|
||||
.scrub.is-dragging .scrub-thumb,
|
||||
.scrub-track:focus-visible .scrub-thumb {
|
||||
opacity: 1;
|
||||
}
|
||||
.scrub.is-dragging .scrub-thumb {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.scrub-bubble {
|
||||
position: absolute;
|
||||
bottom: 18px;
|
||||
transform: translateX(-50%);
|
||||
padding: 2px 6px;
|
||||
border-radius: 5px;
|
||||
background: var(--popover);
|
||||
color: var(--popover-foreground);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scrub.is-compact .scrub-time {
|
||||
display: none;
|
||||
}
|
||||
.scrub.is-compact .scrub-track {
|
||||
height: 14px;
|
||||
}
|
||||
.scrub.is-compact .scrub-track::before,
|
||||
.scrub.is-compact .scrub-buffered,
|
||||
.scrub.is-compact .scrub-played {
|
||||
height: 4px;
|
||||
}
|
||||
.scrub.is-compact:hover .scrub-track::before,
|
||||
.scrub.is-compact:hover .scrub-buffered,
|
||||
.scrub.is-compact:hover .scrub-played,
|
||||
.scrub.is-compact.is-dragging .scrub-track::before,
|
||||
.scrub.is-compact.is-dragging .scrub-buffered,
|
||||
.scrub.is-compact.is-dragging .scrub-played {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scrub.is-disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.scrub-track::before,
|
||||
.scrub-buffered,
|
||||
.scrub-played,
|
||||
.scrub-thumb {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
export interface SegmentedItem {
|
||||
value: string
|
||||
label: string
|
||||
/** lucide 图标组件 */
|
||||
icon?: Component
|
||||
/** 右侧计数徽标 */
|
||||
count?: number
|
||||
}
|
||||
|
||||
/** 模块内统一的分段导航(替代此前并存的三套切换控件) */
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
items: SegmentedItem[]
|
||||
/** 铺满容器宽度(等分) */
|
||||
block?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-center rounded-lg bg-muted p-0.5"
|
||||
:class="block ? 'flex w-full' : 'w-fit'"
|
||||
role="tablist"
|
||||
>
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="modelValue === item.value"
|
||||
class="flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[7px] px-3 py-1.5 text-[13px] transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
:class="
|
||||
modelValue === item.value
|
||||
? 'bg-background font-medium text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click="emit('update:modelValue', item.value)"
|
||||
>
|
||||
<component :is="item.icon" v-if="item.icon" class="size-3.5 shrink-0" />
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
<span
|
||||
v-if="item.count"
|
||||
class="shrink-0 rounded-full bg-muted px-1.5 text-[10px] tabular-nums text-muted-foreground"
|
||||
:class="modelValue === item.value ? 'bg-muted' : 'bg-background/70'"
|
||||
>
|
||||
{{ item.count }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user