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>
180 lines
5.3 KiB
TypeScript
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;
|