feat: redesign player bar (solid dark bg, seek bar, proper nav stacking)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 18s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 18s
This commit is contained in:
parent
96eff9433c
commit
bb860044dd
@ -13,7 +13,7 @@ export function Layout() {
|
|||||||
<Navbar />
|
<Navbar />
|
||||||
<div className="flex flex-1">
|
<div className="flex flex-1">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 max-w-6xl mx-auto w-full px-4 py-6 pb-24 lg:pb-6" style={{ paddingBottom: current ? '7rem' : undefined }}>
|
<main className="flex-1 max-w-6xl mx-auto w-full px-4 py-6" style={{ paddingBottom: current ? '10rem' : '5rem' }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -22,26 +22,24 @@ export function MobileNav() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className={cn(
|
<nav className={cn(
|
||||||
'lg:hidden fixed left-0 right-0 z-40 glass border-t border-border/60',
|
'lg:hidden fixed left-0 right-0 z-40 bg-surface border-t border-border',
|
||||||
current ? 'bottom-[4.75rem]' : 'bottom-0',
|
current ? 'bottom-[4.75rem]' : 'bottom-0',
|
||||||
)}>
|
)}>
|
||||||
<div className="flex items-center justify-around h-[3.5rem] px-2">
|
<div className="flex items-center justify-around h-[3.25rem] px-2">
|
||||||
{links.map(({ to, icon: Icon, label }) => (
|
{links.map(({ to, icon: Icon, label }) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={to}
|
key={to}
|
||||||
to={to}
|
to={to}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'flex flex-col items-center gap-0.5 text-[10px] font-semibold transition-all duration-150 px-3 py-1 rounded-xl',
|
'flex flex-col items-center gap-0.5 text-[10px] font-semibold transition-all duration-150 px-3 py-1',
|
||||||
isActive ? 'text-primary' : 'text-text-secondary',
|
isActive ? 'text-primary' : 'text-text-secondary',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
<>
|
<>
|
||||||
<div className={cn('p-1 rounded-xl transition-colors', isActive && 'bg-primary-soft')}>
|
<Icon size={18} strokeWidth={isActive ? 2.5 : 1.8} />
|
||||||
<Icon size={19} strokeWidth={isActive ? 2.5 : 2} />
|
|
||||||
</div>
|
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useRef } from 'react'
|
||||||
import { Play, Pause, Volume2, VolumeX, SkipBack, SkipForward } from 'lucide-react'
|
import { Play, Pause, Volume2, VolumeX, SkipBack, SkipForward } from 'lucide-react'
|
||||||
import { usePlayerStore } from '@/stores/player'
|
import { usePlayerStore } from '@/stores/player'
|
||||||
import { formatDuration } from '@/lib/utils'
|
import { formatDuration } from '@/lib/utils'
|
||||||
@ -5,95 +6,106 @@ import { Avatar } from '@/components/ui/Avatar'
|
|||||||
|
|
||||||
export function PlayerBar() {
|
export function PlayerBar() {
|
||||||
const { current, isPlaying, isExternal, progress, duration, volume, toggle, seek, setVolume } = usePlayerStore()
|
const { current, isPlaying, isExternal, progress, duration, volume, toggle, seek, setVolume } = usePlayerStore()
|
||||||
|
const seekRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
if (!current) return null
|
if (!current) return null
|
||||||
|
|
||||||
return (
|
const pct = duration ? (progress / duration) * 100 : 0
|
||||||
<div className="fixed bottom-0 left-0 right-0 z-50 glass border-t border-border/60 shadow-[0_-4px_30px_rgba(30,27,51,0.08)]">
|
|
||||||
{/* Progress bar (native audio only) */}
|
|
||||||
{!isExternal && (
|
|
||||||
<div className="relative h-[3px] bg-border-light cursor-pointer group" onClick={(e) => {
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
|
||||||
const pct = (e.clientX - rect.left) / rect.width
|
|
||||||
seek(pct * duration)
|
|
||||||
}}>
|
|
||||||
<div
|
|
||||||
className="absolute inset-y-0 left-0 bg-gradient-to-r from-primary to-[#7B6AEF] transition-all rounded-full"
|
|
||||||
style={{ width: `${duration ? (progress / duration) * 100 : 0}%` }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-primary shadow-[0_0_0_3px_rgba(91,76,219,0.2)] opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
style={{ left: `${duration ? (progress / duration) * 100 : 0}%`, marginLeft: '-6px' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-[4.25rem] flex items-center gap-4">
|
function handleSeek(e: React.MouseEvent<HTMLDivElement>) {
|
||||||
{/* Track info */}
|
if (!seekRef.current || isExternal) return
|
||||||
|
const rect = seekRef.current.getBoundingClientRect()
|
||||||
|
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||||
|
seek(ratio * duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 z-50 bg-[#1E1B33] text-white shadow-[0_-2px_20px_rgba(0,0,0,0.3)]">
|
||||||
|
{/* Seek bar — full width, always visible */}
|
||||||
|
<div
|
||||||
|
ref={seekRef}
|
||||||
|
className="relative h-1.5 bg-white/10 cursor-pointer group"
|
||||||
|
onClick={handleSeek}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 bg-gradient-to-r from-primary to-[#7B6AEF] rounded-r-full transition-[width] duration-100"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
{!isExternal && (
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full bg-white shadow-[0_0_6px_rgba(91,76,219,0.6)] opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style={{ left: `${pct}%`, marginLeft: '-7px' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-[4.5rem] flex items-center gap-3 sm:gap-5">
|
||||||
|
{/* Cover + info */}
|
||||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
{current.cover_url ? (
|
{current.cover_url ? (
|
||||||
<img src={current.cover_url} alt="" className="w-11 h-11 rounded-xl object-cover shadow-organic-sm" />
|
<img src={current.cover_url} alt="" className="w-12 h-12 rounded-lg object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<Avatar name={current.title} size="md" className="!rounded-xl" />
|
<Avatar name={current.title} size="md" className="!rounded-lg !w-12 !h-12" />
|
||||||
)}
|
)}
|
||||||
{isPlaying && (
|
{isPlaying && (
|
||||||
<div className="absolute -bottom-0.5 -right-0.5 flex items-end gap-[2px] h-3 p-[2px] bg-surface rounded-md">
|
<div className="absolute -bottom-0.5 -right-0.5 flex items-end gap-[2px] h-3 p-[2px] bg-[#1E1B33] rounded-md">
|
||||||
<div className="wave-bar" /><div className="wave-bar" /><div className="wave-bar" />
|
<div className="wave-bar !bg-white/80" /><div className="wave-bar !bg-white/80" /><div className="wave-bar !bg-white/80" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[13px] font-semibold truncate">{current.title}</p>
|
<p className="text-[13px] font-semibold truncate text-white">{current.title}</p>
|
||||||
<p className="text-[11px] text-text-secondary truncate">{current.creator?.username}</p>
|
<p className="text-[11px] text-white/50 truncate">{current.creator?.username}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-1.5 sm:gap-3">
|
||||||
{!isExternal && (
|
{!isExternal && (
|
||||||
<button className="text-text-secondary hover:text-primary transition-colors cursor-pointer p-1" onClick={() => seek(Math.max(0, progress - 15))}>
|
<button
|
||||||
<SkipBack size={17} />
|
className="hidden sm:flex text-white/50 hover:text-white transition-colors cursor-pointer p-1.5 rounded-full hover:bg-white/10"
|
||||||
|
onClick={() => seek(Math.max(0, progress - 15))}
|
||||||
|
>
|
||||||
|
<SkipBack size={18} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
className="w-11 h-11 rounded-full bg-gradient-to-br from-primary to-[#7B6AEF] text-white flex items-center justify-center hover:shadow-[0_2px_20px_rgba(91,76,219,0.4)] transition-all active:scale-95 cursor-pointer"
|
className="w-10 h-10 sm:w-12 sm:h-12 rounded-full bg-white text-[#1E1B33] flex items-center justify-center hover:scale-105 transition-transform active:scale-95 cursor-pointer shadow-[0_2px_12px_rgba(255,255,255,0.2)]"
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause size={17} /> : <Play size={17} className="ml-0.5" />}
|
{isPlaying ? <Pause size={18} /> : <Play size={18} className="ml-0.5" />}
|
||||||
</button>
|
</button>
|
||||||
{!isExternal && (
|
{!isExternal && (
|
||||||
<button className="text-text-secondary hover:text-primary transition-colors cursor-pointer p-1" onClick={() => seek(Math.min(duration, progress + 15))}>
|
<button
|
||||||
<SkipForward size={17} />
|
className="hidden sm:flex text-white/50 hover:text-white transition-colors cursor-pointer p-1.5 rounded-full hover:bg-white/10"
|
||||||
|
onClick={() => seek(Math.min(duration, progress + 15))}
|
||||||
|
>
|
||||||
|
<SkipForward size={18} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Volume & time */}
|
{/* Time + Volume */}
|
||||||
<div className="hidden sm:flex items-center gap-3 flex-1 justify-end">
|
<div className="hidden sm:flex items-center gap-3 flex-1 justify-end">
|
||||||
{!isExternal ? (
|
<span className="text-[11px] text-white/40 tabular-nums font-medium min-w-[5rem] text-right">
|
||||||
<>
|
{!isExternal ? `${formatDuration(progress)} / ${formatDuration(duration)}` : duration > 0 ? formatDuration(duration) : ''}
|
||||||
<span className="text-[11px] text-text-secondary tabular-nums font-medium">
|
</span>
|
||||||
{formatDuration(progress)} / {formatDuration(duration)}
|
<button
|
||||||
</span>
|
onClick={() => setVolume(volume === 0 ? 0.8 : 0)}
|
||||||
<button onClick={() => setVolume(volume === 0 ? 0.8 : 0)} className="text-text-secondary hover:text-primary transition-colors cursor-pointer">
|
className="text-white/50 hover:text-white transition-colors cursor-pointer p-1 rounded-full hover:bg-white/10"
|
||||||
{volume === 0 ? <VolumeX size={15} /> : <Volume2 size={15} />}
|
>
|
||||||
</button>
|
{volume === 0 ? <VolumeX size={15} /> : <Volume2 size={15} />}
|
||||||
<input
|
</button>
|
||||||
type="range"
|
<input
|
||||||
min={0}
|
type="range"
|
||||||
max={1}
|
min={0}
|
||||||
step={0.01}
|
max={1}
|
||||||
value={volume}
|
step={0.01}
|
||||||
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
value={volume}
|
||||||
className="w-20"
|
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||||||
/>
|
className="w-20 accent-white [&::-webkit-slider-thumb]:!bg-white [&::-webkit-slider-thumb]:!shadow-none"
|
||||||
</>
|
/>
|
||||||
) : (
|
|
||||||
<span className="text-[11px] text-text-secondary font-medium">
|
|
||||||
{duration > 0 && formatDuration(duration)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -91,8 +91,8 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
input[type="range"] {
|
input[type="range"] {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
height: 5px;
|
height: 4px;
|
||||||
border-radius: 3px;
|
border-radius: 2px;
|
||||||
background: var(--color-border);
|
background: var(--color-border);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
@ -100,8 +100,8 @@ input[type="range"] {
|
|||||||
input[type="range"]::-webkit-slider-thumb {
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
width: 16px;
|
width: 14px;
|
||||||
height: 16px;
|
height: 14px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
box-shadow: 0 0 0 3px rgba(91, 76, 219, 0.2);
|
box-shadow: 0 0 0 3px rgba(91, 76, 219, 0.2);
|
||||||
@ -110,8 +110,18 @@ input[type="range"]::-webkit-slider-thumb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input[type="range"]::-webkit-slider-thumb:hover {
|
input[type="range"]::-webkit-slider-thumb:hover {
|
||||||
transform: scale(1.25);
|
transform: scale(1.2);
|
||||||
box-shadow: 0 0 0 5px rgba(91, 76, 219, 0.15);
|
box-shadow: 0 0 0 4px rgba(91, 76, 219, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Player volume slider (dark bg) */
|
||||||
|
.bg-\[#1E1B33\] input[type="range"] {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-\[#1E1B33\] input[type="range"]::-webkit-slider-thumb {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 0 6px rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Waveform bars animation for active player */
|
/* Waveform bars animation for active player */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user