324 lines
14 KiB
TypeScript
324 lines
14 KiB
TypeScript
import { useRef, useState, useCallback } from 'react'
|
|
import { Play, Pause, Volume2, VolumeX, SkipBack, SkipForward, Maximize2, Minimize2, ListMusic, X } from 'lucide-react'
|
|
import { usePlayerStore } from '@/stores/player'
|
|
import { formatDuration } from '@/lib/utils'
|
|
import { publicUrl } from '@/lib/storage'
|
|
import { Avatar } from '@/components/ui/Avatar'
|
|
import { isExternalUrl, getEmbedInfo } from '@/lib/embed'
|
|
|
|
export function PlayerBar() {
|
|
const { current, isPlaying, isExternal, progress, duration, volume, toggle, seek, setVolume, playbackRate, cyclePlaybackRate, queue, removeFromQueue, clearQueue, playNext, play } = usePlayerStore()
|
|
const seekRef = useRef<HTMLDivElement>(null)
|
|
const [isDragging, setIsDragging] = useState(false)
|
|
const [dragPct, setDragPct] = useState(0)
|
|
const [ytExpanded, setYtExpanded] = useState(false)
|
|
const [queueOpen, setQueueOpen] = useState(false)
|
|
|
|
const calcPct = useCallback((clientX: number) => {
|
|
if (!seekRef.current) return 0
|
|
const rect = seekRef.current.getBoundingClientRect()
|
|
return Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100))
|
|
}, [])
|
|
|
|
if (!current) return null
|
|
|
|
const embedInfo = isExternal && current ? getEmbedInfo(current.audio_url) : null
|
|
const isYouTube = embedInfo?.platform === 'youtube'
|
|
const isSpotify = embedInfo?.platform === 'spotify'
|
|
const chapters = current?.chapters || []
|
|
// Use store duration, fallback to podcast metadata duration
|
|
const effectiveDuration = duration > 0 ? duration : (current?.duration_seconds || 0)
|
|
const currentChapter = chapters.length > 0 && effectiveDuration > 0
|
|
? [...chapters].reverse().find((ch) => progress >= ch.start_time_seconds)
|
|
: null
|
|
|
|
const pct = effectiveDuration > 0 ? (progress / effectiveDuration) * 100 : 0
|
|
const displayPct = isDragging ? dragPct : pct
|
|
// Allow seeking for native audio and YouTube (via IFrame API)
|
|
const canSeek = effectiveDuration > 0
|
|
const showBar = true
|
|
|
|
function startDrag(clientX: number) {
|
|
if (!canSeek) return
|
|
setIsDragging(true)
|
|
setDragPct(calcPct(clientX))
|
|
}
|
|
|
|
function handleMouseDown(e: React.MouseEvent) {
|
|
if (!canSeek) return
|
|
e.preventDefault()
|
|
startDrag(e.clientX)
|
|
|
|
const onMove = (ev: MouseEvent) => setDragPct(calcPct(ev.clientX))
|
|
const onUp = (ev: MouseEvent) => {
|
|
setIsDragging(false)
|
|
const p = calcPct(ev.clientX)
|
|
seek((p / 100) * effectiveDuration)
|
|
window.removeEventListener('mousemove', onMove)
|
|
window.removeEventListener('mouseup', onUp)
|
|
}
|
|
window.addEventListener('mousemove', onMove)
|
|
window.addEventListener('mouseup', onUp)
|
|
}
|
|
|
|
function handleTouchStart(e: React.TouchEvent) {
|
|
if (!canSeek) return
|
|
startDrag(e.touches[0].clientX)
|
|
|
|
const el = seekRef.current!
|
|
const onMove = (ev: TouchEvent) => { ev.preventDefault(); setDragPct(calcPct(ev.touches[0].clientX)) }
|
|
const onEnd = (ev: TouchEvent) => {
|
|
setIsDragging(false)
|
|
seek((calcPct(ev.changedTouches[0].clientX) / 100) * effectiveDuration)
|
|
el.removeEventListener('touchmove', onMove)
|
|
el.removeEventListener('touchend', onEnd)
|
|
}
|
|
el.addEventListener('touchmove', onMove, { passive: false })
|
|
el.addEventListener('touchend', onEnd)
|
|
}
|
|
|
|
function handleClick(e: React.MouseEvent) {
|
|
if (!canSeek || isDragging) return
|
|
seek((calcPct(e.clientX) / 100) * effectiveDuration)
|
|
}
|
|
|
|
const currentTime = isDragging ? (dragPct / 100) * effectiveDuration : progress
|
|
|
|
return (
|
|
<>
|
|
{/* YouTube player - mini or expanded */}
|
|
{isYouTube && (
|
|
<div
|
|
className={`fixed z-[60] rounded-xl overflow-hidden shadow-[0_4px_20px_rgba(0,0,0,0.3)] transition-all duration-300 ${
|
|
ytExpanded
|
|
? 'inset-4 bottom-[106px]'
|
|
: 'bottom-[106px] right-4'
|
|
}`}
|
|
style={ytExpanded ? {} : { width: 200, height: 150 }}
|
|
>
|
|
<div id="yt-player-portal" className="w-full h-full [&_iframe]:!w-full [&_iframe]:!h-full [&_iframe]:!border-0" />
|
|
{/* Gradient overlay to hide YouTube title bar */}
|
|
<div className="absolute inset-x-0 top-0 h-10 bg-gradient-to-b from-black/80 to-transparent pointer-events-none" />
|
|
<button
|
|
onClick={() => setYtExpanded(!ytExpanded)}
|
|
className="absolute top-2 right-2 z-10 w-8 h-8 rounded-lg bg-black/60 hover:bg-black/80 flex items-center justify-center text-white transition-colors cursor-pointer"
|
|
>
|
|
{ytExpanded ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{/* Spotify player - mini floating */}
|
|
{isSpotify && embedInfo && (
|
|
<div className="fixed bottom-[106px] right-4 z-[60] rounded-xl overflow-hidden shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
|
|
<iframe
|
|
src={embedInfo.embedUrl}
|
|
width="300"
|
|
height="152"
|
|
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
|
loading="lazy"
|
|
style={{ border: 0, borderRadius: 12 }}
|
|
/>
|
|
</div>
|
|
)}
|
|
<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)]">
|
|
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
|
{/* Main row */}
|
|
<div className="h-16 flex items-center gap-3 sm:gap-5">
|
|
{/* Cover + info */}
|
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
<div className="relative shrink-0">
|
|
{current.cover_url ? (
|
|
<img src={publicUrl(current.cover_url)} alt="" className="w-11 h-11 rounded-lg object-cover" />
|
|
) : (
|
|
<Avatar name={current.title} size="md" className="!rounded-lg !w-11 !h-11" />
|
|
)}
|
|
{isPlaying && (
|
|
<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 !bg-white/80" /><div className="wave-bar !bg-white/80" /><div className="wave-bar !bg-white/80" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-[13px] font-semibold truncate text-white">{current.title}</p>
|
|
<p className="text-[11px] text-white/50 truncate">
|
|
{currentChapter ? currentChapter.title : current.creator?.username}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div className="flex items-center gap-1.5 sm:gap-3">
|
|
{canSeek && (
|
|
<button
|
|
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
|
|
onClick={toggle}
|
|
className="w-10 h-10 sm:w-11 sm:h-11 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={18} /> : <Play size={18} className="ml-0.5" />}
|
|
</button>
|
|
{canSeek && (
|
|
<button
|
|
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(effectiveDuration, progress + 15))}
|
|
>
|
|
<SkipForward size={18} />
|
|
</button>
|
|
)}
|
|
{/* Speed button — only for native audio + YouTube */}
|
|
{canSeek && !isSpotify && (
|
|
<button
|
|
onClick={cyclePlaybackRate}
|
|
className="hidden sm:flex items-center justify-center text-[11px] font-bold tabular-nums min-w-[2.5rem] h-7 rounded-full transition-colors cursor-pointer"
|
|
style={{ color: playbackRate !== 1 ? '#7B6AEF' : 'rgba(255,255,255,0.5)', background: playbackRate !== 1 ? 'rgba(123,106,239,0.15)' : 'transparent' }}
|
|
title="Vitesse de lecture"
|
|
>
|
|
{playbackRate}x
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Time + Queue + Volume (desktop) */}
|
|
<div className="hidden sm:flex items-center gap-3 flex-1 justify-end">
|
|
<span className="text-[11px] text-white/40 tabular-nums font-medium min-w-[5rem] text-right">
|
|
{effectiveDuration > 0
|
|
? `${formatDuration(currentTime)} / ${formatDuration(effectiveDuration)}`
|
|
: ''}
|
|
</span>
|
|
<button
|
|
onClick={() => setQueueOpen(!queueOpen)}
|
|
className="relative text-white/50 hover:text-white transition-colors cursor-pointer p-1 rounded-full hover:bg-white/10"
|
|
title="File d'attente"
|
|
>
|
|
<ListMusic size={15} />
|
|
{queue.length > 0 && (
|
|
<span className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-[#7B6AEF] text-[9px] font-bold flex items-center justify-center text-white">{queue.length}</span>
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={() => setVolume(volume === 0 ? 0.8 : 0)}
|
|
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>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={1}
|
|
step={0.01}
|
|
value={volume}
|
|
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
|
className="w-20 accent-white"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Seek bar */}
|
|
{showBar && <div
|
|
ref={seekRef}
|
|
className={`relative ${canSeek ? 'cursor-pointer' : ''}`}
|
|
onMouseDown={canSeek ? handleMouseDown : undefined}
|
|
onTouchStart={canSeek ? handleTouchStart : undefined}
|
|
onClick={canSeek ? handleClick : undefined}
|
|
style={{ touchAction: 'none', height: '24px', marginBottom: '4px' }}
|
|
>
|
|
{/* Track background — centered vertically */}
|
|
<div
|
|
className="absolute left-0 right-0 rounded-full"
|
|
style={{ height: '5px', background: 'rgba(255,255,255,0.15)', top: '50%', transform: 'translateY(-50%)' }}
|
|
>
|
|
{/* Track filled */}
|
|
<div
|
|
className="absolute inset-y-0 left-0 rounded-full"
|
|
style={{
|
|
width: `${displayPct}%`,
|
|
background: 'linear-gradient(90deg, #5B4CDB, #7B6AEF)',
|
|
}}
|
|
/>
|
|
{/* Chapter markers */}
|
|
{effectiveDuration > 0 && chapters.map((ch) => (
|
|
<div
|
|
key={ch.id}
|
|
className="absolute top-1/2 -translate-y-1/2 w-[3px] h-[11px] rounded-full bg-white/40"
|
|
style={{ left: `${(ch.start_time_seconds / effectiveDuration) * 100}%` }}
|
|
title={ch.title}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Thumb — centered on track */}
|
|
{canSeek && (
|
|
<div
|
|
className="absolute"
|
|
style={{
|
|
left: `calc(${displayPct}% - 8px)`,
|
|
top: '50%',
|
|
transform: 'translateY(-50%)',
|
|
width: '16px',
|
|
height: '16px',
|
|
borderRadius: '50%',
|
|
background: '#ffffff',
|
|
boxShadow: '0 0 10px rgba(91,76,219,0.7), 0 0 0 2px rgba(255,255,255,0.3)',
|
|
transition: isDragging ? 'none' : 'left 0.1s',
|
|
zIndex: 2,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>}
|
|
</div>
|
|
</div>
|
|
{/* Queue panel */}
|
|
{queueOpen && (
|
|
<div className="fixed bottom-[106px] right-4 z-[60] w-80 max-h-[60vh] rounded-xl bg-[#1E1B33] border border-white/10 shadow-[0_4px_30px_rgba(0,0,0,0.4)] overflow-hidden flex flex-col">
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10">
|
|
<span className="text-sm font-semibold text-white">File d'attente ({queue.length})</span>
|
|
<div className="flex items-center gap-2">
|
|
{queue.length > 0 && (
|
|
<button onClick={clearQueue} className="text-[11px] text-white/40 hover:text-white/70 transition-colors cursor-pointer">
|
|
Vider
|
|
</button>
|
|
)}
|
|
<button onClick={() => setQueueOpen(false)} className="text-white/40 hover:text-white transition-colors cursor-pointer">
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="overflow-y-auto flex-1">
|
|
{queue.length === 0 ? (
|
|
<p className="text-sm text-white/30 text-center py-8">La file est vide</p>
|
|
) : (
|
|
queue.map((p, i) => (
|
|
<div key={p.id} className="flex items-center gap-3 px-4 py-2.5 hover:bg-white/5 transition-colors group">
|
|
<span className="text-[11px] text-white/30 w-4 text-center tabular-nums">{i + 1}</span>
|
|
{p.cover_url ? (
|
|
<img src={publicUrl(p.cover_url)} alt="" className="w-9 h-9 rounded-lg object-cover shrink-0" />
|
|
) : (
|
|
<div className="w-9 h-9 rounded-lg bg-white/10 flex items-center justify-center shrink-0">
|
|
<span className="text-[11px] font-bold text-white/30">{p.title[0]?.toUpperCase()}</span>
|
|
</div>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-[12px] font-medium text-white truncate">{p.title}</p>
|
|
<p className="text-[10px] text-white/40 truncate">{p.external_author || p.creator?.username}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => removeFromQueue(p.id)}
|
|
className="opacity-0 group-hover:opacity-100 text-white/30 hover:text-white/70 transition-all cursor-pointer p-1"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|