Apply i18n to all components, add collapsible sidebar, playback speed control, inline title editing, and ConfirmDialog for deletions
Phase 4: i18n all 15 components from PR #19. Sidebar gains collapse/expand toggle. Player gets playback speed selector (0.25x-2.0x) fixing bug B1 (hardcoded Chinese '正常'). SongList adds inline title editing and model version badge. Delete actions now use ConfirmDialog instead of window.confirm. Volume persisted to localStorage. Training nav item deferred to Phase 7.
This commit is contained in:
+131
-38
@@ -3,6 +3,7 @@ import { Song } from '../types';
|
||||
import { Play, Pause, SkipBack, SkipForward, Repeat, Shuffle, Download, Heart, MoreVertical, Volume2, VolumeX, Maximize2, Repeat1, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useResponsive } from '../context/ResponsiveContext';
|
||||
import { useI18n } from '../context/I18nContext';
|
||||
import { SongDropdownMenu } from './SongDropdownMenu';
|
||||
import { ShareModal } from './ShareModal';
|
||||
import { AlbumCover } from './AlbumCover';
|
||||
@@ -18,6 +19,9 @@ interface PlayerProps {
|
||||
onPrevious: () => void;
|
||||
volume: number;
|
||||
onVolumeChange: (val: number) => void;
|
||||
playbackRate: number;
|
||||
onPlaybackRateChange: (rate: number) => void;
|
||||
audioRef: React.RefObject<HTMLAudioElement>;
|
||||
isShuffle: boolean;
|
||||
onToggleShuffle: () => void;
|
||||
repeatMode: 'none' | 'all' | 'one';
|
||||
@@ -42,6 +46,9 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
onPrevious,
|
||||
volume,
|
||||
onVolumeChange,
|
||||
playbackRate,
|
||||
onPlaybackRateChange,
|
||||
audioRef,
|
||||
isShuffle,
|
||||
onToggleShuffle,
|
||||
repeatMode,
|
||||
@@ -56,12 +63,15 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const { isMobile } = useResponsive();
|
||||
const { t } = useI18n();
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
const fullscreenProgressRef = useRef<HTMLDivElement>(null);
|
||||
const [isHoveringVolume, setIsHoveringVolume] = useState(false);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
|
||||
const speedMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close fullscreen on Escape key
|
||||
useEffect(() => {
|
||||
@@ -75,6 +85,17 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isFullscreen]);
|
||||
|
||||
// Close speed menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (speedMenuRef.current && !speedMenuRef.current.contains(event.target as Node)) {
|
||||
setShowSpeedMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Show minimal player when no song is playing
|
||||
if (!currentSong) {
|
||||
return (
|
||||
@@ -137,7 +158,7 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
<span className="text-xs text-zinc-500 dark:text-white/50 uppercase tracking-wider">Now Playing</span>
|
||||
<span className="text-xs text-zinc-500 dark:text-white/50 uppercase tracking-wider">{t('nowPlaying')}</span>
|
||||
<div className="w-11" />
|
||||
</div>
|
||||
|
||||
@@ -236,15 +257,9 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Volume Control */}
|
||||
<div className="flex items-center gap-3 px-6 py-4">
|
||||
<button
|
||||
onClick={() => onVolumeChange(volume === 0 ? 0.8 : 0)}
|
||||
className="text-zinc-400 dark:text-white/50 tap-highlight-none"
|
||||
>
|
||||
{volume === 0 ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||
</button>
|
||||
<div className="flex-1 h-1 bg-zinc-300 dark:bg-white/20 rounded-full relative">
|
||||
{/* Volume Control - Vertical */}
|
||||
<div className="flex flex-col items-center gap-3 px-6 py-4">
|
||||
<div className="relative h-32 w-8 flex items-center justify-center">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
@@ -252,13 +267,19 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={(e) => onVolumeChange(parseFloat(e.target.value))}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-zinc-700 dark:bg-white/70 rounded-full"
|
||||
style={{ width: `${volume * 100}%` }}
|
||||
className="w-32 h-8 -rotate-90 origin-center appearance-none bg-transparent cursor-pointer"
|
||||
style={{
|
||||
WebkitAppearance: 'none',
|
||||
background: `linear-gradient(to right, rgb(236 72 153) 0%, rgb(236 72 153) ${volume * 100}%, rgb(228 228 231) ${volume * 100}%, rgb(228 228 231) 100%)`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onVolumeChange(volume === 0 ? 0.8 : 0)}
|
||||
className="text-zinc-400 dark:text-white/50 tap-highlight-none"
|
||||
>
|
||||
{volume === 0 ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Extra Actions */}
|
||||
@@ -271,7 +292,7 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-3 tap-highlight-none"
|
||||
title="Download Audio"
|
||||
title={t('downloadAudio')}
|
||||
>
|
||||
<Download size={20} />
|
||||
</button>
|
||||
@@ -295,7 +316,7 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
onCreateVideo={onOpenVideo}
|
||||
onReusePrompt={onReusePrompt}
|
||||
onAddToPlaylist={onAddToPlaylist}
|
||||
onDelete={onDelete}
|
||||
onDelete={onDelete}
|
||||
onShare={() => setShareModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
@@ -391,7 +412,7 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
<span className="text-sm text-zinc-500 dark:text-white/50 uppercase tracking-wider font-medium">Now Playing</span>
|
||||
<span className="text-sm text-zinc-500 dark:text-white/50 uppercase tracking-wider font-medium">{t('nowPlaying')}</span>
|
||||
<div className="w-11" />
|
||||
</div>
|
||||
|
||||
@@ -484,6 +505,34 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Playback Speed Dropdown */}
|
||||
<div className="relative group hidden lg:block" ref={speedMenuRef}>
|
||||
<button
|
||||
className="px-2 py-1 text-[11px] font-mono font-bold hover:bg-zinc-200 dark:hover:bg-white/10 rounded transition-colors min-w-[42px] text-center"
|
||||
onClick={() => setShowSpeedMenu(!showSpeedMenu)}
|
||||
>
|
||||
{playbackRate}x
|
||||
</button>
|
||||
{showSpeedMenu && (
|
||||
<div className="absolute bottom-full right-0 mb-2 bg-white dark:bg-zinc-800 rounded-lg shadow-xl border border-zinc-200 dark:border-white/10 py-1 min-w-[80px] z-50">
|
||||
{[0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0].map((rate) => (
|
||||
<button
|
||||
key={rate}
|
||||
onClick={() => {
|
||||
onPlaybackRateChange(rate);
|
||||
setShowSpeedMenu(false);
|
||||
}}
|
||||
className={`w-full px-3 py-1.5 text-left text-xs font-mono hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors ${
|
||||
playbackRate === rate ? 'text-pink-600 dark:text-pink-500 font-bold' : 'text-zinc-700 dark:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{rate === 1.0 ? t('normalSpeed') : `${rate}x`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Volume Control */}
|
||||
<div className="flex items-center gap-4 w-full max-w-xs">
|
||||
<button
|
||||
@@ -506,6 +555,12 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
className="h-full bg-zinc-700 dark:bg-white/70 rounded-full"
|
||||
style={{ width: `${volume * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 bg-zinc-700 dark:bg-white/70 rounded-full shadow pointer-events-none"
|
||||
style={{
|
||||
left: `clamp(0px, calc(${volume * 100}% - 7px), calc(100% - 14px))`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -528,7 +583,7 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-3 rounded-full hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"
|
||||
title="Download Audio"
|
||||
title={t('downloadAudio')}
|
||||
>
|
||||
<Download size={20} />
|
||||
</button>
|
||||
@@ -582,7 +637,7 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
className="h-full bg-zinc-900 dark:bg-white relative group-hover:bg-pink-600 dark:group-hover:bg-pink-500 transition-colors"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-zinc-900 dark:bg-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity shadow-lg scale-150"></div>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-zinc-900 dark:bg-white group-hover:bg-pink-600 dark:group-hover:bg-pink-500 rounded-full shadow-lg -mr-2 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
{/* Hit area for easier clicking */}
|
||||
<div className="absolute top-1/2 -translate-y-1/2 w-full h-4 -z-10"></div>
|
||||
@@ -658,8 +713,37 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
{formatTime(currentTime)} / {formatTime(duration || 0)}
|
||||
</span>
|
||||
|
||||
{/* Playback Speed */}
|
||||
<div className="relative group hidden lg:block" ref={speedMenuRef}>
|
||||
<button
|
||||
className="px-2 py-1 text-[11px] font-mono font-bold hover:bg-zinc-200 dark:hover:bg-white/10 rounded transition-colors min-w-[42px] text-center"
|
||||
onClick={() => setShowSpeedMenu(!showSpeedMenu)}
|
||||
>
|
||||
{playbackRate}x
|
||||
</button>
|
||||
{showSpeedMenu && (
|
||||
<div className="absolute bottom-full right-0 mb-2 bg-white dark:bg-zinc-800 rounded-lg shadow-xl border border-zinc-200 dark:border-white/10 py-1 min-w-[80px] z-50">
|
||||
{[0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0].map((rate) => (
|
||||
<button
|
||||
key={rate}
|
||||
onClick={() => {
|
||||
onPlaybackRateChange(rate);
|
||||
setShowSpeedMenu(false);
|
||||
}}
|
||||
className={`w-full px-3 py-1.5 text-left text-xs font-mono hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors ${
|
||||
playbackRate === rate ? 'text-pink-600 dark:text-pink-500 font-bold' : 'text-zinc-700 dark:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{rate === 1.0 ? t('normalSpeed') : `${rate}x`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Volume Control with Vertical Slider */}
|
||||
<div
|
||||
className="items-center gap-2 relative group hidden md:flex"
|
||||
className="relative group hidden md:block"
|
||||
onMouseEnter={() => setIsHoveringVolume(true)}
|
||||
onMouseLeave={() => setIsHoveringVolume(false)}
|
||||
>
|
||||
@@ -670,28 +754,37 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
{volume === 0 ? <VolumeX size={18} /> : <Volume2 size={18} />}
|
||||
</button>
|
||||
|
||||
{/* Volume Slider */}
|
||||
<div className={`h-1.5 bg-zinc-200 dark:bg-zinc-700 rounded-full cursor-pointer overflow-hidden transition-all duration-200 ${isHoveringVolume ? 'opacity-100 w-16 lg:w-24 mx-1 lg:mx-2' : 'opacity-0 w-0 mx-0 pointer-events-none'}`}>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={(e) => onVolumeChange(parseFloat(e.target.value))}
|
||||
className="w-full h-full opacity-0 cursor-pointer absolute z-10"
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-zinc-900 dark:bg-white rounded-full"
|
||||
style={{ width: `${volume * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
{/* Vertical Volume Slider */}
|
||||
{isHoveringVolume && (
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 pb-2">
|
||||
<div className="bg-white dark:bg-zinc-800 rounded-lg shadow-xl border border-zinc-200 dark:border-white/10 p-2">
|
||||
<div className="relative h-24 w-8 flex items-center justify-center">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={(e) => onVolumeChange(parseFloat(e.target.value))}
|
||||
className="w-24 h-8 -rotate-90 origin-center appearance-none bg-transparent cursor-pointer"
|
||||
style={{
|
||||
WebkitAppearance: 'none',
|
||||
background: `linear-gradient(to right, rgb(236 72 153) 0%, rgb(236 72 153) ${volume * 100}%, rgb(228 228 231) ${volume * 100}%, rgb(228 228 231) 100%)`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] text-center font-mono text-zinc-600 dark:text-zinc-400 mt-1">
|
||||
{Math.round(volume * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-1.5 lg:p-2 hover:bg-zinc-100 dark:hover:bg-white/10 rounded-full transition-colors hidden lg:block"
|
||||
title="Download Audio"
|
||||
title={t('downloadAudio')}
|
||||
>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user