feat: Marquee scrolling track name in player bar

Player.tsx:
- Track name now scrolls horizontally when hovered (marquee effect)
- Uses CSS @keyframes animation with duplicated text for seamless loop
- Animation duration scales with text length (min 6s, ~0.3s per char)
- Default state: truncate with ellipsis. Hover: whitespace-nowrap + animate
- Wrapped player in React fragment with inline <style> for keyframes
- Uses Tailwind group-hover to trigger animation only on hover
This commit is contained in:
rin.kagamine committed 2026-05-29 20:46:05 -04:00
1 parent 56bb484fd9
commit 5e18d77e29
2 files changed
+379 -154

No files matched your search

+176 -154
View File
@@ -163,171 +163,193 @@ export default function Player({ clip, isPlaying, onTogglePlay, onStop, onClose
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
const displayTitle = clip.title || clip.prompt
const marqueeDuration = Math.max(6, displayTitle.length * 0.3)
return (
<div className="fixed bottom-0 left-0 right-0 bg-lyria-panel border-t border-white/10 px-4 py-3 z-40">
<div className="max-w-6xl mx-auto flex items-center gap-3">
{/* Play/Pause */}
<button
onClick={onTogglePlay}
className="w-9 h-9 rounded-full bg-lyria-accent flex items-center justify-center text-white hover:bg-lyria-accent/90 transition-colors shrink-0"
title={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
) : (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
{/* Stop */}
<button
onClick={handleStop}
className="w-7 h-7 rounded flex items-center justify-center text-lyria-muted hover:text-white hover:bg-white/5 transition-colors shrink-0"
title="Stop"
>
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="1" />
</svg>
</button>
{/* Restart — ISO skip backwards to beginning */}
<button
onClick={handleRestart}
className="w-7 h-7 rounded flex items-center justify-center text-lyria-muted hover:text-white hover:bg-white/5 transition-colors shrink-0"
title="Skip to beginning"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h2v12H6V6zm3.5 6l8.5 6V6l-8.5 6z" />
</svg>
</button>
{/* Repeat — ISO repeat indefinitely with glowing toggle */}
<button
onClick={toggleRepeat}
className={`w-7 h-7 rounded flex items-center justify-center transition-colors shrink-0 ${
isRepeating
? 'text-lyria-accent bg-lyria-accent/10 shadow-[0_0_8px_rgba(255,107,53,0.4)]'
: 'text-lyria-muted hover:text-white hover:bg-white/5'
}`}
title={isRepeating ? 'Repeat on' : 'Repeat off'}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
{/* Time + Scrubber */}
<div className="flex-1 min-w-0 flex items-center gap-3">
<span className="text-xs text-lyria-muted tabular-nums shrink-0 w-10 text-right">
{formatTime(currentTime)}
</span>
<div
ref={progressRef}
className="flex-1 h-2 bg-white/10 rounded-full cursor-pointer relative group"
onMouseDown={handleProgressMouseDown}
onMouseMove={handleProgressMouseMove}
onMouseUp={handleProgressMouseUp}
>
<div
className="absolute inset-y-0 left-0 bg-lyria-accent rounded-full transition-all duration-75"
style={{ width: `${progress}%` }}
/>
{/* Scrubber handle */}
<div
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow opacity-0 group-hover:opacity-100 transition-opacity"
style={{ left: `calc(${progress}% - 6px)` }}
/>
</div>
<span className="text-xs text-lyria-muted tabular-nums shrink-0 w-10">
{formatTime(duration)}
</span>
</div>
{/* Track info with artwork */}
<div className="hidden sm:flex items-center gap-2.5 min-w-0 max-w-[220px] shrink-0">
<div className="w-8 h-8 rounded bg-white/5 flex items-center justify-center shrink-0 overflow-hidden">
<img
src={`/api/clips/${clip.id}/artwork`}
alt=""
className="w-full h-full object-cover"
onError={(e) => {
// Fallback to treble clef on error
const target = e.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent) {
parent.innerHTML = `<svg class='w-4 h-4 text-lyria-accent/40' viewBox='0 0 24 24'><text x='12' y='19' text-anchor='middle' font-size='16' fill='currentColor'>𝄞</text></svg>`
}
}}
/>
</div>
<p className="text-xs text-white truncate">{displayTitle}</p>
</div>
{/* Volume */}
<div className="flex items-center gap-2 shrink-0">
<>
<style>{`
@keyframes marquee-scroll {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
.animate-marquee {
animation: marquee-scroll linear infinite;
}
`}</style>
<div className="fixed bottom-0 left-0 right-0 bg-lyria-panel border-t border-white/10 px-4 py-3 z-40">
<div className="max-w-6xl mx-auto flex items-center gap-3">
{/* Play/Pause */}
<button
onClick={toggleMute}
className="text-lyria-muted hover:text-white transition-colors"
title={isMuted ? 'Unmute' : 'Mute'}
onClick={onTogglePlay}
className="w-9 h-9 rounded-full bg-lyria-accent flex items-center justify-center text-white hover:bg-lyria-accent/90 transition-colors shrink-0"
title={isPlaying ? 'Pause' : 'Play'}
>
{isMuted || volume === 0 ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
</svg>
) : volume < 0.5 ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072" />
{isPlaying ? (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="w-20 h-1 bg-white/20 rounded-full appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-2.5 [&::-webkit-slider-thumb]:h-2.5
[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full
[&::-moz-range-thumb]:w-2.5 [&::-moz-range-thumb]:h-2.5 [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0"
/>
{/* Stop */}
<button
onClick={handleStop}
className="w-7 h-7 rounded flex items-center justify-center text-lyria-muted hover:text-white hover:bg-white/5 transition-colors shrink-0"
title="Stop"
>
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="1" />
</svg>
</button>
{/* Restart — ISO skip backwards to beginning */}
<button
onClick={handleRestart}
className="w-7 h-7 rounded flex items-center justify-center text-lyria-muted hover:text-white hover:bg-white/5 transition-colors shrink-0"
title="Skip to beginning"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h2v12H6V6zm3.5 6l8.5 6V6l-8.5 6z" />
</svg>
</button>
{/* Repeat — ISO repeat indefinitely with glowing toggle */}
<button
onClick={toggleRepeat}
className={`w-7 h-7 rounded flex items-center justify-center transition-colors shrink-0 ${
isRepeating
? 'text-lyria-accent bg-lyria-accent/10 shadow-[0_0_8px_rgba(255,107,53,0.4)]'
: 'text-lyria-muted hover:text-white hover:bg-white/5'
}`}
title={isRepeating ? 'Repeat on' : 'Repeat off'}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
{/* Time + Scrubber */}
<div className="flex-1 min-w-0 flex items-center gap-3">
<span className="text-xs text-lyria-muted tabular-nums shrink-0 w-10 text-right">
{formatTime(currentTime)}
</span>
<div
ref={progressRef}
className="flex-1 h-2 bg-white/10 rounded-full cursor-pointer relative group"
onMouseDown={handleProgressMouseDown}
onMouseMove={handleProgressMouseMove}
onMouseUp={handleProgressMouseUp}
>
<div
className="absolute inset-y-0 left-0 bg-lyria-accent rounded-full transition-all duration-75"
style={{ width: `${progress}%` }}
/>
{/* Scrubber handle */}
<div
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow opacity-0 group-hover:opacity-100 transition-opacity"
style={{ left: `calc(${progress}% - 6px)` }}
/>
</div>
<span className="text-xs text-lyria-muted tabular-nums shrink-0 w-10">
{formatTime(duration)}
</span>
</div>
{/* Track info with artwork */}
<div className="hidden sm:flex items-center gap-2.5 min-w-0 max-w-[220px] shrink-0">
<div className="w-8 h-8 rounded bg-white/5 flex items-center justify-center shrink-0 overflow-hidden">
<img
src={`/api/clips/${clip.id}/artwork`}
alt=""
className="w-full h-full object-cover"
onError={(e) => {
// Fallback to treble clef on error
const target = e.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent) {
parent.innerHTML = `<svg class='w-4 h-4 text-lyria-accent/40' viewBox='0 0 24 24'><text x='12' y='19' text-anchor='middle' font-size='16' fill='currentColor'>𝄞</text></svg>`
}
}}
/>
</div>
{/* Marquee scrolling track name */}
<div className="overflow-hidden flex-1 min-w-0 group">
<div
className="text-xs text-white truncate group-hover:whitespace-nowrap group-hover:animate-marquee"
style={{ animationDuration: `${marqueeDuration}s` }}
>
{displayTitle}
<span className="inline-block w-8" />
{displayTitle}
</div>
</div>
</div>
{/* Volume */}
<div className="flex items-center gap-2 shrink-0">
<button
onClick={toggleMute}
className="text-lyria-muted hover:text-white transition-colors"
title={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted || volume === 0 ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
</svg>
) : volume < 0.5 ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
</svg>
)}
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="w-20 h-1 bg-white/20 rounded-full appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-2.5 [&::-webkit-slider-thumb]:h-2.5
[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full
[&::-moz-range-thumb]:w-2.5 [&::-moz-range-thumb]:h-2.5 [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0"
/>
</div>
{/* Close */}
<button
onClick={onClose}
className="text-lyria-muted hover:text-white transition-colors shrink-0"
title="Close player"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Close */}
<button
onClick={onClose}
className="text-lyria-muted hover:text-white transition-colors shrink-0"
title="Close player"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<audio
ref={audioRef}
src={`/api/clips/${clip.id}/audio`}
onLoadedMetadata={handleLoadedMetadata}
onTimeUpdate={handleTimeUpdate}
onEnded={handleEnded}
className="hidden"
/>
</div>
<audio
ref={audioRef}
src={`/api/clips/${clip.id}/audio`}
onLoadedMetadata={handleLoadedMetadata}
onTimeUpdate={handleTimeUpdate}
onEnded={handleEnded}
className="hidden"
/>
</div>
</>
)
}