veza/apps/web/src/features/player/components/VolumeControl.tsx
senke aeade50247 feat(ui): tooltip adoption + search highlighting & skeleton loading
Tooltip adoption (18 conversions across 11 files):
- Player controls: shuffle, repeat, mute, expand, close, lyrics, auto-scroll
- Navbar: theme toggle
- File browser: download, add tag, AI auto-tag, watermark, process with AI
- Notifications: mark as read
- Share links: open link, revoke link
- Chat: scroll to bottom

Search polish:
- New highlightMatch utility — wraps matching text in <mark> with primary color
- Applied to track titles, artist names, playlist names in SearchPageResults
- Applied to suggestion dropdown titles and subtitles
- Replaced spinner loading state with content-aware SearchPageSkeleton
- Skeleton matches actual results layout (tab bar, track cards, artist circles)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 23:14:00 +01:00

180 lines
5.3 KiB
TypeScript

/**
* Composant VolumeControl
* Contrôle du volume avec slider, bouton mute, affichage valeur et persistance
*/
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Volume2, VolumeX, Volume1 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Tooltip } from '@/components/ui/tooltip';
export interface VolumeControlProps {
volume: number; // 0-100
muted: boolean;
onVolumeChange: (volume: number) => void;
onMuteToggle: () => void;
className?: string;
disabled?: boolean;
showValue?: boolean;
showSlider?: boolean;
}
export function VolumeControl({
volume,
muted,
onVolumeChange,
onMuteToggle,
className,
disabled = false,
showValue = false,
showSlider = true,
}: VolumeControlProps) {
const [isDragging, setIsDragging] = useState(false);
const sliderRef = useRef<HTMLDivElement>(null);
const displayVolume = muted ? 0 : volume;
const getTimeFromPosition = useCallback(
(clientX: number): number => {
if (!sliderRef.current) return volume;
const rect = sliderRef.current.getBoundingClientRect();
const x = clientX - rect.left;
const percentage = Math.max(0, Math.min(1, x / rect.width));
return Math.round(percentage * 100);
},
[volume],
);
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (disabled) return;
setIsDragging(true);
const newVolume = getTimeFromPosition(e.clientX);
onVolumeChange(newVolume);
},
[disabled, getTimeFromPosition, onVolumeChange],
);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging || disabled) return;
const newVolume = getTimeFromPosition(e.clientX);
onVolumeChange(newVolume);
},
[isDragging, disabled, getTimeFromPosition, onVolumeChange],
);
const handleMouseUp = useCallback(() => {
if (isDragging) {
setIsDragging(false);
}
}, [isDragging]);
useEffect(() => {
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}
return undefined;
}, [isDragging, handleMouseMove, handleMouseUp]);
const getVolumeIcon = () => {
if (muted || displayVolume === 0) {
return <VolumeX className="h-5 w-5" aria-hidden="true" />;
}
if (displayVolume < 50) {
return <Volume1 className="h-5 w-5" aria-hidden="true" />;
}
return <Volume2 className="h-5 w-5" aria-hidden="true" />;
};
const getVolumeLabel = () => {
if (muted) return 'Volume muet';
return `Volume: ${volume}%`;
};
return (
<div className={cn('flex items-center gap-2', className)}>
{/* Mute Button */}
<Tooltip content={getVolumeLabel()} disabled={disabled}>
<button
type="button"
onClick={onMuteToggle}
disabled={disabled}
className={cn(
'rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 h-10 w-10',
'bg-transparent text-foreground hover:bg-muted focus:ring-muted',
disabled && 'opacity-50 cursor-not-allowed',
)}
aria-label={getVolumeLabel()}
aria-pressed={muted}
aria-disabled={disabled}
>
{getVolumeIcon()}
<span className="sr-only">{getVolumeLabel()}</span>
</button>
</Tooltip>
{/* Volume Slider */}
{showSlider && (
<div className="flex items-center gap-2 flex-1 min-w-0">
<div
ref={sliderRef}
className={cn(
'relative w-full h-2 group cursor-pointer',
disabled && 'cursor-not-allowed opacity-50',
)}
onMouseDown={handleMouseDown}
role="slider"
aria-label="Volume"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={displayVolume}
aria-disabled={disabled}
>
{/* Background track */}
<div className="absolute inset-0 bg-muted rounded-full" />
{/* Volume track */}
<div
className={cn(
'absolute left-0 top-0 h-full bg-primary rounded-full transition-all',
isDragging && 'bg-primary',
)}
style={{ width: `${displayVolume}%` }}
/>
{/* Thumb */}
<div
className={cn(
'absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-primary rounded-full',
'opacity-0 group-hover:opacity-100 transition-opacity',
isDragging && 'opacity-100',
disabled && 'opacity-0',
)}
style={{ left: `calc(${displayVolume}% - 8px)` }}
/>
</div>
{/* Volume Value */}
{showValue && (
<span
className="text-xs text-muted-foreground min-w-8 text-right"
aria-label={`Volume: ${volume}%`}
>
{muted ? 'Mute' : `${volume}%`}
</span>
)}
</div>
)}
</div>
);
}
export default VolumeControl;