feat(slider): 添加仅可拖动滑块的自定义音量滑块组件

- 新增 ThumbOnlyRangeSlider 组件,禁用轨道点击,仅支持滑块拖动
- 避免触摸时点击轨道导致音量跳变问题
- 在 Home 页面替换旧的音量输入滑块为新组件
- 移除旧音量滑块相关多余的引用和函数
- 支持拖动开始、结束和释放回调以优化操作逻辑
- 优化了 BrandDrawer 组件的样式以支持不同单位的视口高度显示
This commit is contained in:
eafonyang
2026-09-15 17:09:02 +08:00
parent 0bab1bb968
commit eb00a417f7
3 changed files with 175 additions and 65 deletions
@@ -0,0 +1,162 @@
import { cn } from "@/lib/utils";
import { useRef, type CSSProperties } from "react";
/** Generous hit area around the thumb for touch. */
const THUMB_HIT_RADIUS_PX = 24;
/** Half of the .cyan-slider thumb (~17.6-18px wide); the thumb center never reaches the track ends. */
const THUMB_HALF_WIDTH_PX = 9;
function thumbCenterXPx(trackWidth: number, min: number, max: number, value: number): number {
const span = max - min;
const ratio = span > 0 ? (value - min) / span : 0;
const travel = Math.max(0, trackWidth - THUMB_HALF_WIDTH_PX * 2);
return THUMB_HALF_WIDTH_PX + ratio * travel;
}
function valueFromOffsetXPx(trackWidth: number, min: number, max: number, offsetX: number): number {
const travel = Math.max(1, trackWidth - THUMB_HALF_WIDTH_PX * 2);
const ratio = Math.max(0, Math.min(1, (offsetX - THUMB_HALF_WIDTH_PX) / travel));
return Math.round(min + ratio * (max - min));
}
const RELEASE_KEYS = [
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Home",
"End",
"PageUp",
"PageDown",
] as const;
export interface ThumbOnlyRangeSliderProps {
value: number;
min: number;
max: number;
disabled?: boolean;
className?: string;
style?: CSSProperties;
onChange: (value: number) => void;
/** Fires when the user releases the thumb after a drag or keyboard adjust. */
onRelease?: (value: number) => void;
onDragStart?: () => void;
onDragEnd?: () => void;
}
/**
* Range slider that ignores track clicks/taps — only the thumb can be dragged.
* Prevents accidental jumps to high volume when missing the thumb.
*
* The native <input type="range"> is rendered pointer-events:none and driven
* entirely by wrapper pointer events. On touch input the native tap-to-position
* behavior cannot be reliably canceled via preventDefault on pointerdown (React
* listens to touchstart passively at the root, and the touch-driven path differs
* from desktop mouse), so the only robust fix is to remove the native pointer
* interaction surface altogether. The input stays keyboard-focusable for a11y.
*/
export default function ThumbOnlyRangeSlider({
value,
min,
max,
disabled = false,
className,
style,
onChange,
onRelease,
onDragStart,
onDragEnd,
}: ThumbOnlyRangeSliderProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
const dragRef = useRef<{ pointerId: number; grabOffsetPx: number } | null>(null);
const valueRef = useRef(value);
valueRef.current = value;
const finishDrag = (pointerId: number | null) => {
const drag = dragRef.current;
if (!drag) return;
if (pointerId !== null && drag.pointerId !== pointerId) return;
dragRef.current = null;
onRelease?.(valueRef.current);
onDragEnd?.();
};
return (
<div
className={cn(
"relative z-10 flex h-6 w-full items-center",
disabled ? "cursor-default" : "cursor-pointer",
className,
)}
style={{ touchAction: "none", ...style }}
onPointerDown={(e) => {
if (disabled || dragRef.current) return;
if (e.pointerType === "mouse" && e.button !== 0) return;
const input = inputRef.current;
if (!input) return;
const rect = input.getBoundingClientRect();
const pressX = e.clientX - rect.left;
const centerX = thumbCenterXPx(rect.width, min, max, valueRef.current);
// Track press: ignore entirely — no value change, no drag.
if (Math.abs(pressX - centerX) > THUMB_HIT_RADIUS_PX) return;
e.preventDefault();
dragRef.current = { pointerId: e.pointerId, grabOffsetPx: pressX - centerX };
onDragStart?.();
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
const drag = dragRef.current;
if (disabled || !drag || drag.pointerId !== e.pointerId) return;
const input = inputRef.current;
if (!input) return;
const rect = input.getBoundingClientRect();
const offsetX = e.clientX - rect.left - drag.grabOffsetPx;
onChange(valueFromOffsetXPx(rect.width, min, max, offsetX));
}}
onPointerUp={(e) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== e.pointerId) return;
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
// ignore
}
finishDrag(e.pointerId);
}}
onPointerCancel={() => finishDrag(null)}
onLostPointerCapture={() => finishDrag(null)}
>
<input
ref={inputRef}
type="range"
min={min}
max={max}
value={value}
disabled={disabled}
className="cyan-slider block w-full"
style={{ pointerEvents: "none", touchAction: "none" }}
onChange={(e) => {
// Only reachable via keyboard — the input no longer receives pointer input.
if (disabled || dragRef.current) return;
onChange(Number(e.target.value));
}}
onBlur={(e) => {
if (dragRef.current) {
finishDrag(null);
return;
}
if (!disabled) {
onRelease?.(Number(e.currentTarget.value));
}
}}
onKeyUp={(e) => {
if (disabled) return;
if (RELEASE_KEYS.includes(e.key as (typeof RELEASE_KEYS)[number])) {
onRelease?.(Number(e.currentTarget.value));
onDragEnd?.();
}
}}
/>
</div>
);
}
+12 -64
View File
@@ -12,6 +12,7 @@
import { useDevice } from "@/contexts/DeviceContext"; import { useDevice } from "@/contexts/DeviceContext";
import ConnectionPlaceholder from "@/components/ConnectionPlaceholder"; import ConnectionPlaceholder from "@/components/ConnectionPlaceholder";
import BottomNav from "@/components/BottomNav"; import BottomNav from "@/components/BottomNav";
import ThumbOnlyRangeSlider from "@/components/ThumbOnlyRangeSlider";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -231,8 +232,6 @@ export default function Home() {
const [localVol, setLocalVol] = useState(deviceState?.volume ?? 100); const [localVol, setLocalVol] = useState(deviceState?.volume ?? 100);
const isDragging = useRef(false); const isDragging = useRef(false);
const volumeSliderRef = useRef<HTMLInputElement>(null);
const volumeSliderDragAllowedRef = useRef(false);
const knobDraggingRef = useRef(false); const knobDraggingRef = useRef(false);
const knobActivePointerIdRef = useRef<number | null>(null); const knobActivePointerIdRef = useRef<number | null>(null);
const knobStartAngleRef = useRef(0); const knobStartAngleRef = useRef(0);
@@ -249,16 +248,6 @@ export default function Home() {
knobLastVolRef.current = localVol; knobLastVolRef.current = localVol;
}, [localVol]); }, [localVol]);
const isPointerOnVolumeSliderThumb = useCallback((clientX: number) => {
const el = volumeSliderRef.current;
if (!el) return false;
const rect = el.getBoundingClientRect();
if (rect.width <= 0) return false;
const thumbCenterX = rect.left + (localVol / 200) * rect.width;
const hitRadius = Math.max(28, rect.width * 0.05);
return Math.abs(clientX - thumbCenterX) <= hitRadius;
}, [localVol]);
const handlePowerOff = useCallback(async () => { const handlePowerOff = useCallback(async () => {
if (powerActionRef.current) return; if (powerActionRef.current) return;
powerActionRef.current = true; powerActionRef.current = true;
@@ -677,67 +666,26 @@ export default function Home() {
background: "#00FFF6", background: "#00FFF6",
boxShadow: "0 0 8px rgba(0,255,246,0.5)" boxShadow: "0 0 8px rgba(0,255,246,0.5)"
}} /> }} />
<input <ThumbOnlyRangeSlider
ref={volumeSliderRef} min={0}
type="range" min={0} max={200} value={localVol} max={200}
value={localVol}
disabled={volumePassthroughActive} disabled={volumePassthroughActive}
className="cyan-slider cyan-slider-thumb-only relative z-10 disabled:opacity-50" className={volumePassthroughActive ? "opacity-50" : undefined}
onChange={(e) => { onChange={(v) => {
if (volumePassthroughActive) return; if (volumePassthroughActive) return;
if (!volumeSliderDragAllowedRef.current) {
e.target.value = String(localVol);
return;
}
const v = Number(e.target.value);
setLocalVol(v); setLocalVol(v);
}} }}
onPointerDown={(e) => { onDragStart={() => {
if (volumePassthroughActive) return; if (volumePassthroughActive) return;
const onThumb = isPointerOnVolumeSliderThumb(e.clientX);
volumeSliderDragAllowedRef.current = onThumb;
if (!onThumb) {
e.preventDefault();
return;
}
isDragging.current = true; isDragging.current = true;
}} }}
onPointerUp={(e) => { onDragEnd={() => {
const allowed = volumeSliderDragAllowedRef.current;
volumeSliderDragAllowedRef.current = false;
isDragging.current = false; isDragging.current = false;
if (!allowed || volumePassthroughActive) return;
applyVolume(Number(e.currentTarget.value));
}} }}
onPointerCancel={(e) => { onRelease={(v) => {
const allowed = volumeSliderDragAllowedRef.current; if (volumePassthroughActive) return;
volumeSliderDragAllowedRef.current = false; applyVolume(v);
isDragging.current = false;
if (!volumePassthroughActive && allowed) {
applyVolume(Number(e.currentTarget.value));
}
}}
onBlur={(e) => {
const allowed = volumeSliderDragAllowedRef.current;
volumeSliderDragAllowedRef.current = false;
isDragging.current = false;
if (!volumePassthroughActive && allowed) {
applyVolume(Number(e.currentTarget.value));
}
}}
onKeyUp={(e) => {
const k = e.key;
if (
k === "ArrowLeft" ||
k === "ArrowRight" ||
k === "ArrowUp" ||
k === "ArrowDown" ||
k === "Home" ||
k === "End" ||
k === "PageUp" ||
k === "PageDown"
) {
applyVolume(Number(e.currentTarget.value));
}
}} }}
/> />
</div> </div>
@@ -164,7 +164,7 @@ export function BrandDrawer({
onClick={onClose} onClick={onClose}
/> />
<div <div
className="relative z-10 flex h-[58dvh] flex-shrink-0 flex-col overflow-hidden rounded-t-[18px] bg-white shadow-[0_-8px_32px_rgba(0,0,0,0.35)]" className="relative z-10 flex h-[58vh] max-h-full flex-shrink-0 flex-col overflow-hidden rounded-t-[18px] bg-white shadow-[0_-8px_32px_rgba(0,0,0,0.35)] supports-[height:1dvh]:h-[58dvh]"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex shrink-0 items-center justify-center pt-2 pb-1"> <div className="flex shrink-0 items-center justify-center pt-2 pb-1">