2025-12-03 21:56:50 +00:00
|
|
|
/**
|
|
|
|
|
* 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';
|
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 22:14:00 +00:00
|
|
|
import { Tooltip } from '@/components/ui/tooltip';
|
2025-12-03 21:56:50 +00:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
},
|
2025-12-13 02:34:34 +00:00
|
|
|
[volume],
|
2025-12-03 21:56:50 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleMouseDown = useCallback(
|
|
|
|
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
|
|
|
if (disabled) return;
|
|
|
|
|
|
|
|
|
|
setIsDragging(true);
|
|
|
|
|
const newVolume = getTimeFromPosition(e.clientX);
|
|
|
|
|
onVolumeChange(newVolume);
|
|
|
|
|
},
|
2025-12-13 02:34:34 +00:00
|
|
|
[disabled, getTimeFromPosition, onVolumeChange],
|
2025-12-03 21:56:50 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleMouseMove = useCallback(
|
|
|
|
|
(e: MouseEvent) => {
|
|
|
|
|
if (!isDragging || disabled) return;
|
|
|
|
|
|
|
|
|
|
const newVolume = getTimeFromPosition(e.clientX);
|
|
|
|
|
onVolumeChange(newVolume);
|
|
|
|
|
},
|
2025-12-13 02:34:34 +00:00
|
|
|
[isDragging, disabled, getTimeFromPosition, onVolumeChange],
|
2025-12-03 21:56:50 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
}
|
2025-12-27 17:40:36 +00:00
|
|
|
return undefined;
|
2025-12-03 21:56:50 +00:00
|
|
|
}, [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 (
|
2026-01-13 18:47:57 +00:00
|
|
|
<div className={cn('flex items-center gap-2', className)}>
|
2025-12-03 21:56:50 +00:00
|
|
|
{/* Mute Button */}
|
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 22:14:00 +00:00
|
|
|
<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>
|
2025-12-03 21:56:50 +00:00
|
|
|
|
|
|
|
|
{/* 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',
|
2025-12-13 02:34:34 +00:00
|
|
|
disabled && 'cursor-not-allowed opacity-50',
|
2025-12-03 21:56:50 +00:00
|
|
|
)}
|
|
|
|
|
onMouseDown={handleMouseDown}
|
|
|
|
|
role="slider"
|
|
|
|
|
aria-label="Volume"
|
|
|
|
|
aria-valuemin={0}
|
|
|
|
|
aria-valuemax={100}
|
|
|
|
|
aria-valuenow={displayVolume}
|
|
|
|
|
aria-disabled={disabled}
|
|
|
|
|
>
|
|
|
|
|
{/* Background track */}
|
2026-02-07 14:33:31 +00:00
|
|
|
<div className="absolute inset-0 bg-muted rounded-full" />
|
2025-12-03 21:56:50 +00:00
|
|
|
|
|
|
|
|
{/* Volume track */}
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
2026-02-07 15:01:56 +00:00
|
|
|
'absolute left-0 top-0 h-full bg-primary rounded-full transition-all',
|
|
|
|
|
isDragging && 'bg-primary',
|
2025-12-03 21:56:50 +00:00
|
|
|
)}
|
|
|
|
|
style={{ width: `${displayVolume}%` }}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Thumb */}
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
2026-02-07 15:01:56 +00:00
|
|
|
'absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-primary rounded-full',
|
2025-12-03 21:56:50 +00:00
|
|
|
'opacity-0 group-hover:opacity-100 transition-opacity',
|
|
|
|
|
isDragging && 'opacity-100',
|
2025-12-13 02:34:34 +00:00
|
|
|
disabled && 'opacity-0',
|
2025-12-03 21:56:50 +00:00
|
|
|
)}
|
|
|
|
|
style={{ left: `calc(${displayVolume}% - 8px)` }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Volume Value */}
|
|
|
|
|
{showValue && (
|
|
|
|
|
<span
|
feat(web): UI premium Discord/Spotify-like — tokens, shadows, focus, layout
Plan UI premium 6–8 semaines (design system, shell, Storybook, a11y):
- Design system: DESIGN_TOKENS.md, APP_SHELL.md, FULL_LAYOUT_PAGE.md. Single source
for layout/shell (index.css), shadows (design-system.css), durations/easing.
- Tokens: shadow-cover-depth, shadow-gold-glow, shadow-fab-glow; layout max-height
(max-h-layout-drawer, max-h-layout-panel, max-h-layout-list). All duration-200/300/500
replaced by --duration-fast/normal/slow. Arbitrary shadows replaced by token classes.
- Shell & player: Sidebar, Header, GlobalPlayer, MiniPlayer, PlayerQueue, PlayerControls,
AudioPlayer use tokens; focus-visible on Sidebar, PlayerQueue, DropdownMenuTrigger/Item,
TabsTrigger. Typography: text-[10px]/[9px] → text-xs where applicable.
- ESLint: no-restricted-syntax (warn) for w-/h-/rounded-/shadow-/text-/spacing arbitrary.
- Scripts: report-arbitrary-values.mjs, capture/compare/generate visual; visual-complete.spec.ts.
- Stories full layout: Dashboard, Playlists, Library, Settings, Profile in DashboardLayout.stories.
- .cursorrules + README: DESIGN_TOKENS, APP_SHELL, visual commands, no arbitrary without justification.
- apps/web/.gitignore: e2e test artifacts (test-results-visual, playwright-report-visual).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 16:15:58 +00:00
|
|
|
className="text-xs text-muted-foreground min-w-8 text-right"
|
2025-12-03 21:56:50 +00:00
|
|
|
aria-label={`Volume: ${volume}%`}
|
|
|
|
|
>
|
|
|
|
|
{muted ? 'Mute' : `${volume}%`}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default VolumeControl;
|