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:
fspecii
2026-02-08 18:35:09 +02:00
parent 1b13f01b3d
commit 325046eaf2
17 changed files with 760 additions and 431 deletions
+131 -38
View File
@@ -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>