c91fdd7e1d
- PlayerBar redesigned: canvas waveform seekbar (500 bars, blue→mauve gradient + glow) replaces thin slider; new flex layout; queue toggle moved to content header (consistent with sidebar pattern) - MilkDrop visualizer in Ambient Stage via Butterchurn; hidden audio analysis, preset shuffle, smooth blend transitions - Tracklist: animated EQ bars for playing track, play icon when paused, align-items: center fix - Artist pages: Last.fm + Wikipedia open in native Tauri WebviewWindow (in-app browser) - Hero/Discover deduplication: single fetch of 20 random albums split between sections - Update checker: runs every 10 minutes during runtime; version shown without v-prefix - Settings version: now read from package.json instead of hardcoded - Help page: new Random Mix section, updated Playback + Library sections, improved accordion styling - Bump version to 1.3.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
155 lines
5.8 KiB
TypeScript
155 lines
5.8 KiB
TypeScript
import React, { useCallback, useMemo } from 'react';
|
|
import {
|
|
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
|
|
Square, Repeat, Repeat1, Maximize2
|
|
} from 'lucide-react';
|
|
import { usePlayerStore } from '../store/playerStore';
|
|
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
|
import CachedImage from './CachedImage';
|
|
import WaveformSeek from './WaveformSeek';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
|
function formatTime(seconds: number): string {
|
|
if (!seconds || isNaN(seconds)) return '0:00';
|
|
const m = Math.floor(seconds / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
export default function PlayerBar() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const {
|
|
currentTrack, isPlaying, currentTime, volume,
|
|
togglePlay, next, previous, setVolume,
|
|
stop, toggleRepeat, repeatMode, toggleFullscreen,
|
|
} = usePlayerStore();
|
|
|
|
const duration = currentTrack?.duration ?? 0;
|
|
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
|
|
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '';
|
|
|
|
const handleVolume = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setVolume(parseFloat(e.target.value));
|
|
}, [setVolume]);
|
|
|
|
const volumeStyle = {
|
|
background: `linear-gradient(to right, var(--ctp-mauve) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
|
|
};
|
|
|
|
return (
|
|
<footer className="player-bar" role="region" aria-label={t('player.regionLabel')}>
|
|
|
|
{/* Track Info */}
|
|
<div className="player-track-info">
|
|
<div
|
|
className={`player-album-art-wrap ${currentTrack ? 'clickable' : ''}`}
|
|
onClick={() => currentTrack && toggleFullscreen()}
|
|
data-tooltip={currentTrack ? t('player.openFullscreen') : undefined}
|
|
>
|
|
{currentTrack?.coverArt ? (
|
|
<CachedImage
|
|
className="player-album-art"
|
|
src={coverSrc}
|
|
cacheKey={coverKey}
|
|
alt={`${currentTrack.album} Cover`}
|
|
/>
|
|
) : (
|
|
<div className="player-album-art-placeholder">
|
|
<Music size={22} />
|
|
</div>
|
|
)}
|
|
{currentTrack && (
|
|
<div className="player-art-expand-hint" aria-hidden="true">
|
|
<Maximize2 size={16} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="player-track-meta">
|
|
<div
|
|
className="player-track-name"
|
|
data-tooltip={currentTrack?.title ?? ''}
|
|
style={{ cursor: currentTrack?.albumId ? 'pointer' : 'default' }}
|
|
onClick={() => currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
|
>
|
|
{currentTrack?.title ?? t('player.noTitle')}
|
|
</div>
|
|
<div
|
|
className="player-track-artist"
|
|
data-tooltip={currentTrack?.artist ?? ''}
|
|
style={{ cursor: currentTrack?.artistId ? 'pointer' : 'default' }}
|
|
onClick={() => currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
|
>
|
|
{currentTrack?.artist ?? '—'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Transport Controls */}
|
|
<div className="player-buttons">
|
|
<button className="player-btn player-btn-sm" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
|
|
<Square size={14} fill="currentColor" />
|
|
</button>
|
|
<button className="player-btn" onClick={previous} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
|
<SkipBack size={19} />
|
|
</button>
|
|
<button
|
|
className="player-btn player-btn-primary"
|
|
onClick={togglePlay}
|
|
aria-label={isPlaying ? t('player.pause') : t('player.play')}
|
|
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
|
|
>
|
|
{isPlaying ? <Pause size={22} /> : <Play size={22} fill="currentColor" />}
|
|
</button>
|
|
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
|
<SkipForward size={19} />
|
|
</button>
|
|
<button
|
|
className="player-btn player-btn-sm"
|
|
onClick={toggleRepeat}
|
|
aria-label={t('player.repeat')}
|
|
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
|
|
style={{ color: repeatMode !== 'off' ? 'var(--accent)' : undefined }}
|
|
>
|
|
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Waveform Seekbar */}
|
|
<div className="player-waveform-section">
|
|
<span className="player-time">{formatTime(currentTime)}</span>
|
|
<div className="player-waveform-wrap">
|
|
<WaveformSeek trackId={currentTrack?.id} />
|
|
</div>
|
|
<span className="player-time">{formatTime(duration)}</span>
|
|
</div>
|
|
|
|
{/* Volume */}
|
|
<div className="player-volume-section">
|
|
<button
|
|
className="player-btn player-btn-sm"
|
|
onClick={() => setVolume(volume === 0 ? 0.7 : 0)}
|
|
aria-label={t('player.volume')}
|
|
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
|
>
|
|
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
|
</button>
|
|
<input
|
|
type="range"
|
|
id="player-volume"
|
|
min={0}
|
|
max={1}
|
|
step={0.01}
|
|
value={volume}
|
|
onChange={handleVolume}
|
|
style={volumeStyle}
|
|
aria-label={t('player.volume')}
|
|
className="player-volume-slider"
|
|
/>
|
|
</div>
|
|
|
|
</footer>
|
|
);
|
|
}
|