音乐模块调整

This commit is contained in:
2026-09-12 15:47:16 +08:00
parent d702ed0d31
commit ffa410b399
33 changed files with 5452 additions and 2237 deletions
+293
View File
@@ -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>
+55
View File
@@ -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>