Compare commits
No commits in common. "143e453f78f4e4510c4d825d19b7b6db5f274614" and "cc4ced307674a74eb9d5be1009e52b76c945e800" have entirely different histories.
143e453f78
...
cc4ced3076
@ -3,8 +3,7 @@
|
|||||||
"allow": [
|
"allow": [
|
||||||
"Bash(npm install:*)",
|
"Bash(npm install:*)",
|
||||||
"WebFetch(domain:archive.org)",
|
"WebFetch(domain:archive.org)",
|
||||||
"WebSearch",
|
"WebSearch"
|
||||||
"WebFetch(domain:inv.nadeko.net)"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
FROM node:22-alpine
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package*.json .npmrc ./
|
|
||||||
RUN npm ci
|
|
||||||
COPY . .
|
|
||||||
EXPOSE 5173
|
|
||||||
CMD ["./node_modules/.bin/vite", "--host", "0.0.0.0"]
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
services:
|
|
||||||
app:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.dev
|
|
||||||
ports:
|
|
||||||
- "5173:5173"
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
- /app/node_modules
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
- CHOKIDAR_USEPOLLING=true
|
|
||||||
@ -11,7 +11,6 @@ import { PodcastDetail } from '@/pages/PodcastDetail'
|
|||||||
import { Profile } from '@/pages/Profile'
|
import { Profile } from '@/pages/Profile'
|
||||||
import { Favorites } from '@/pages/Favorites'
|
import { Favorites } from '@/pages/Favorites'
|
||||||
import { Settings } from '@/pages/Settings'
|
import { Settings } from '@/pages/Settings'
|
||||||
import { MyPodcasts } from '@/pages/MyPodcasts'
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { setUser, fetchProfile } = useAuthStore()
|
const { setUser, fetchProfile } = useAuthStore()
|
||||||
@ -40,7 +39,6 @@ export default function App() {
|
|||||||
<Route path="podcast/:id" element={<PodcastDetail />} />
|
<Route path="podcast/:id" element={<PodcastDetail />} />
|
||||||
<Route path="profile/:username" element={<Profile />} />
|
<Route path="profile/:username" element={<Profile />} />
|
||||||
<Route path="favorites" element={<Favorites />} />
|
<Route path="favorites" element={<Favorites />} />
|
||||||
<Route path="my-podcasts" element={<MyPodcasts />} />
|
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@ -1,195 +1,112 @@
|
|||||||
import { useRef, useState, useCallback } from 'react'
|
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'
|
||||||
import { publicUrl } from '@/lib/storage'
|
|
||||||
import { Avatar } from '@/components/ui/Avatar'
|
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)
|
const seekRef = useRef<HTMLDivElement>(null)
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
|
||||||
const [dragPct, setDragPct] = useState(0)
|
|
||||||
|
|
||||||
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
|
if (!current) return null
|
||||||
|
|
||||||
const pct = duration > 0 ? (progress / duration) * 100 : 0
|
const pct = duration ? (progress / duration) * 100 : 0
|
||||||
const displayPct = isDragging ? dragPct : pct
|
|
||||||
// Allow seeking for native audio and YouTube (via IFrame API)
|
|
||||||
const canSeek = duration > 0
|
|
||||||
const showBar = true
|
|
||||||
|
|
||||||
function startDrag(clientX: number) {
|
function handleSeek(e: React.MouseEvent<HTMLDivElement>) {
|
||||||
if (!canSeek) return
|
if (!seekRef.current || isExternal) return
|
||||||
setIsDragging(true)
|
const rect = seekRef.current.getBoundingClientRect()
|
||||||
setDragPct(calcPct(clientX))
|
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||||
|
seek(ratio * duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
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) * duration)
|
|
||||||
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) * duration)
|
|
||||||
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) * duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentTime = isDragging ? (dragPct / 100) * duration : progress
|
|
||||||
|
|
||||||
return (
|
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)]">
|
<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">
|
{/* Seek bar — full width, always visible */}
|
||||||
{/* Main row */}
|
<div
|
||||||
<div className="h-16 flex items-center gap-3 sm:gap-5">
|
ref={seekRef}
|
||||||
{/* Cover + info */}
|
className="relative h-1.5 bg-white/10 cursor-pointer group"
|
||||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
onClick={handleSeek}
|
||||||
<div className="relative shrink-0">
|
>
|
||||||
{current.cover_url ? (
|
<div
|
||||||
<img src={publicUrl(current.cover_url)} alt="" className="w-11 h-11 rounded-lg object-cover" />
|
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}%` }}
|
||||||
<Avatar name={current.title} size="md" className="!rounded-lg !w-11 !h-11" />
|
/>
|
||||||
)}
|
{!isExternal && (
|
||||||
{isPlaying && (
|
<div
|
||||||
<div className="absolute -bottom-0.5 -right-0.5 flex items-end gap-[2px] h-3 p-[2px] bg-[#1E1B33] rounded-md">
|
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"
|
||||||
<div className="wave-bar !bg-white/80" /><div className="wave-bar !bg-white/80" /><div className="wave-bar !bg-white/80" />
|
style={{ left: `${pct}%`, marginLeft: '-7px' }}
|
||||||
</div>
|
/>
|
||||||
)}
|
)}
|
||||||
</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">{current.creator?.username}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls */}
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-[4.5rem] flex items-center gap-3 sm:gap-5">
|
||||||
<div className="flex items-center gap-1.5 sm:gap-3">
|
{/* Cover + info */}
|
||||||
{canSeek && (
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
<button
|
<div className="relative shrink-0">
|
||||||
className="hidden sm:flex text-white/50 hover:text-white transition-colors cursor-pointer p-1.5 rounded-full hover:bg-white/10"
|
{current.cover_url ? (
|
||||||
onClick={() => seek(Math.max(0, progress - 15))}
|
<img src={current.cover_url} alt="" className="w-12 h-12 rounded-lg object-cover" />
|
||||||
>
|
) : (
|
||||||
<SkipBack size={18} />
|
<Avatar name={current.title} size="md" className="!rounded-lg !w-12 !h-12" />
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
<button
|
{isPlaying && (
|
||||||
onClick={toggle}
|
<div className="absolute -bottom-0.5 -right-0.5 flex items-end gap-[2px] h-3 p-[2px] bg-[#1E1B33] rounded-md">
|
||||||
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)]"
|
<div className="wave-bar !bg-white/80" /><div className="wave-bar !bg-white/80" /><div className="wave-bar !bg-white/80" />
|
||||||
>
|
</div>
|
||||||
{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(duration, progress + 15))}
|
|
||||||
>
|
|
||||||
<SkipForward size={18} />
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
{/* Time + Volume (desktop) */}
|
<p className="text-[13px] font-semibold truncate text-white">{current.title}</p>
|
||||||
<div className="hidden sm:flex items-center gap-3 flex-1 justify-end">
|
<p className="text-[11px] text-white/50 truncate">{current.creator?.username}</p>
|
||||||
<span className="text-[11px] text-white/40 tabular-nums font-medium min-w-[5rem] text-right">
|
|
||||||
{duration > 0
|
|
||||||
? `${formatDuration(currentTime)} / ${formatDuration(duration)}`
|
|
||||||
: ''}
|
|
||||||
</span>
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Seek bar */}
|
{/* Controls */}
|
||||||
{showBar && <div
|
<div className="flex items-center gap-1.5 sm:gap-3">
|
||||||
ref={seekRef}
|
{!isExternal && (
|
||||||
className={`relative ${canSeek ? 'cursor-pointer' : ''}`}
|
<button
|
||||||
onMouseDown={canSeek ? handleMouseDown : undefined}
|
className="hidden sm:flex text-white/50 hover:text-white transition-colors cursor-pointer p-1.5 rounded-full hover:bg-white/10"
|
||||||
onTouchStart={canSeek ? handleTouchStart : undefined}
|
onClick={() => seek(Math.max(0, progress - 15))}
|
||||||
onClick={canSeek ? handleClick : undefined}
|
>
|
||||||
style={{ touchAction: 'none', height: '24px', marginBottom: '4px' }}
|
<SkipBack size={18} />
|
||||||
>
|
</button>
|
||||||
{/* 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)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</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>}
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
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={18} /> : <Play size={18} className="ml-0.5" />}
|
||||||
|
</button>
|
||||||
|
{!isExternal && (
|
||||||
|
<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(duration, progress + 15))}
|
||||||
|
>
|
||||||
|
<SkipForward size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time + Volume */}
|
||||||
|
<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">
|
||||||
|
{!isExternal ? `${formatDuration(progress)} / ${formatDuration(duration)}` : duration > 0 ? formatDuration(duration) : ''}
|
||||||
|
</span>
|
||||||
|
<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 [&::-webkit-slider-thumb]:!bg-white [&::-webkit-slider-thumb]:!shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { Play, Pause, Heart, MessageCircle, Clock } from 'lucide-react'
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import type { Podcast } from '@/types'
|
import type { Podcast } from '@/types'
|
||||||
import { formatDuration, timeAgo } from '@/lib/utils'
|
import { formatDuration, timeAgo } from '@/lib/utils'
|
||||||
import { publicUrl } from '@/lib/storage'
|
|
||||||
import { Badge } from '@/components/ui/Badge'
|
import { Badge } from '@/components/ui/Badge'
|
||||||
import { Avatar } from '@/components/ui/Avatar'
|
import { Avatar } from '@/components/ui/Avatar'
|
||||||
import { usePlayerStore } from '@/stores/player'
|
import { usePlayerStore } from '@/stores/player'
|
||||||
@ -22,10 +21,9 @@ function titleToGradient(title: string) {
|
|||||||
|
|
||||||
interface PodcastCardProps {
|
interface PodcastCardProps {
|
||||||
podcast: Podcast
|
podcast: Podcast
|
||||||
progressPercent?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PodcastCard({ podcast, progressPercent }: PodcastCardProps) {
|
export function PodcastCard({ podcast }: PodcastCardProps) {
|
||||||
const play = usePlayerStore((s) => s.play)
|
const play = usePlayerStore((s) => s.play)
|
||||||
const currentId = usePlayerStore((s) => s.current?.id)
|
const currentId = usePlayerStore((s) => s.current?.id)
|
||||||
const isPlaying = usePlayerStore((s) => s.isPlaying)
|
const isPlaying = usePlayerStore((s) => s.isPlaying)
|
||||||
@ -36,7 +34,7 @@ export function PodcastCard({ podcast, progressPercent }: PodcastCardProps) {
|
|||||||
{/* Cover */}
|
{/* Cover */}
|
||||||
<div className="relative aspect-[4/3]">
|
<div className="relative aspect-[4/3]">
|
||||||
{podcast.cover_url ? (
|
{podcast.cover_url ? (
|
||||||
<img src={publicUrl(podcast.cover_url)} alt="" className="w-full h-full object-cover" />
|
<img src={podcast.cover_url} alt="" className="w-full h-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<div className={`w-full h-full flex items-center justify-center bg-gradient-to-br ${titleToGradient(podcast.title)}`}>
|
<div className={`w-full h-full flex items-center justify-center bg-gradient-to-br ${titleToGradient(podcast.title)}`}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@ -77,13 +75,6 @@ export function PodcastCard({ podcast, progressPercent }: PodcastCardProps) {
|
|||||||
<div className="wave-bar" /><div className="wave-bar" /><div className="wave-bar" /><div className="wave-bar" />
|
<div className="wave-bar" /><div className="wave-bar" /><div className="wave-bar" /><div className="wave-bar" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Listening progress bar */}
|
|
||||||
{progressPercent != null && progressPercent > 0 && (
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/20">
|
|
||||||
<div className="h-full bg-primary rounded-r-full" style={{ width: `${Math.min(progressPercent, 100)}%` }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
|
|||||||
@ -124,59 +124,6 @@ input[type="range"]::-webkit-slider-thumb:hover {
|
|||||||
box-shadow: 0 0 6px rgba(255, 255, 255, 0.3);
|
box-shadow: 0 0 6px rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Seek slider — custom styled range input for the player */
|
|
||||||
.seek-slider {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 100%;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.seek-slider::-webkit-slider-runnable-track {
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
.seek-slider::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #ffffff;
|
|
||||||
box-shadow: 0 0 8px rgba(91, 76, 219, 0.5), 0 0 0 2px rgba(255, 255, 255, 0.3);
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: -5px;
|
|
||||||
transition: transform 0.15s;
|
|
||||||
}
|
|
||||||
.seek-slider::-webkit-slider-thumb:hover {
|
|
||||||
transform: scale(1.3);
|
|
||||||
}
|
|
||||||
.seek-slider::-moz-range-track {
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
}
|
|
||||||
.seek-slider::-moz-range-thumb {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #ffffff;
|
|
||||||
border: none;
|
|
||||||
box-shadow: 0 0 8px rgba(91, 76, 219, 0.5), 0 0 0 2px rgba(255, 255, 255, 0.3);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.seek-slider::-moz-range-thumb:hover {
|
|
||||||
transform: scale(1.3);
|
|
||||||
}
|
|
||||||
.seek-slider::-moz-range-progress {
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: linear-gradient(90deg, #5B4CDB, #7B6AEF);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Waveform bars animation for active player */
|
/* Waveform bars animation for active player */
|
||||||
@keyframes wave {
|
@keyframes wave {
|
||||||
0%, 100% { height: 8px; }
|
0%, 100% { height: 8px; }
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || ''
|
|
||||||
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || ''
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Self-hosted Supabase storage requires the apikey even for public buckets.
|
|
||||||
* This appends the anon key to any Supabase storage URL so browsers can fetch it directly.
|
|
||||||
*/
|
|
||||||
export function publicUrl(url: string | null | undefined): string {
|
|
||||||
if (!url) return ''
|
|
||||||
// Only add apikey for our own Supabase storage URLs
|
|
||||||
if (!SUPABASE_ANON_KEY || !url.includes(SUPABASE_URL + '/storage/')) return url
|
|
||||||
const sep = url.includes('?') ? '&' : '?'
|
|
||||||
return `${url}${sep}apikey=${SUPABASE_ANON_KEY}`
|
|
||||||
}
|
|
||||||
@ -1,27 +1,20 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { TrendingUp, Clock, ArrowRight, Headphones, Users, Mic, PlayCircle } from 'lucide-react'
|
import { TrendingUp, Clock, ArrowRight, Headphones, Users, Mic } from 'lucide-react'
|
||||||
import { supabase } from '@/lib/supabase'
|
import { supabase } from '@/lib/supabase'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
import type { Podcast } from '@/types'
|
import type { Podcast } from '@/types'
|
||||||
import { PodcastCard } from '@/components/podcast/PodcastCard'
|
import { PodcastCard } from '@/components/podcast/PodcastCard'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
|
|
||||||
interface InProgressPodcast extends Podcast {
|
|
||||||
progressPercent: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const [trending, setTrending] = useState<Podcast[]>([])
|
const [trending, setTrending] = useState<Podcast[]>([])
|
||||||
const [recent, setRecent] = useState<Podcast[]>([])
|
const [recent, setRecent] = useState<Podcast[]>([])
|
||||||
const [inProgress, setInProgress] = useState<InProgressPodcast[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const { user } = useAuthStore()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
const queries: Promise<any>[] = [
|
const [trendingRes, recentRes] = await Promise.all([
|
||||||
supabase
|
supabase
|
||||||
.from('podcasts')
|
.from('podcasts')
|
||||||
.select('*, creator:profiles!podcasts_creator_id_fkey(*), tags:podcast_tags(tag:tags(*))')
|
.select('*, creator:profiles!podcasts_creator_id_fkey(*), tags:podcast_tags(tag:tags(*))')
|
||||||
@ -32,43 +25,14 @@ export function Home() {
|
|||||||
.select('*, creator:profiles!podcasts_creator_id_fkey(*), tags:podcast_tags(tag:tags(*))')
|
.select('*, creator:profiles!podcasts_creator_id_fkey(*), tags:podcast_tags(tag:tags(*))')
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(8),
|
.limit(8),
|
||||||
]
|
])
|
||||||
|
|
||||||
// Fetch in-progress podcasts if logged in
|
|
||||||
if (user) {
|
|
||||||
queries.push(
|
|
||||||
supabase
|
|
||||||
.from('listen_history')
|
|
||||||
.select('progress_seconds, podcast:podcasts(*, creator:profiles!podcasts_creator_id_fkey(*), tags:podcast_tags(tag:tags(*)))')
|
|
||||||
.eq('user_id', user.id)
|
|
||||||
.eq('completed', false)
|
|
||||||
.gt('progress_seconds', 5)
|
|
||||||
.order('listened_at', { ascending: false })
|
|
||||||
.limit(6)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.all(queries)
|
|
||||||
|
|
||||||
if (results[0].data) setTrending(normalizePodcasts(results[0].data))
|
|
||||||
if (results[1].data) setRecent(normalizePodcasts(results[1].data))
|
|
||||||
|
|
||||||
if (user && results[2]?.data) {
|
|
||||||
const items = results[2].data
|
|
||||||
.filter((r: any) => r.podcast)
|
|
||||||
.map((r: any) => {
|
|
||||||
const p = normalizePodcasts([r.podcast])[0]
|
|
||||||
const pct = p.duration_seconds > 0 ? (r.progress_seconds / p.duration_seconds) * 100 : 0
|
|
||||||
return { ...p, progressPercent: pct }
|
|
||||||
})
|
|
||||||
.filter((p: InProgressPodcast) => p.progressPercent > 0 && p.progressPercent < 95)
|
|
||||||
setInProgress(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (trendingRes.data) setTrending(normalizePodcasts(trendingRes.data))
|
||||||
|
if (recentRes.data) setRecent(normalizePodcasts(recentRes.data))
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
}, [user])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-14">
|
<div className="space-y-14">
|
||||||
@ -122,24 +86,6 @@ export function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Continue listening */}
|
|
||||||
{inProgress.length > 0 && (
|
|
||||||
<section>
|
|
||||||
<div className="mb-5">
|
|
||||||
<h2 className="flex items-center gap-2 text-xl font-heading font-extrabold">
|
|
||||||
<PlayCircle size={20} strokeWidth={2.5} />
|
|
||||||
Reprendre l'ecoute
|
|
||||||
</h2>
|
|
||||||
<p className="text-[13px] text-text-secondary mt-0.5">La ou vous vous etes arrete</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-5">
|
|
||||||
{inProgress.map((p) => (
|
|
||||||
<PodcastCard key={p.id} podcast={p} progressPercent={p.progressPercent} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PodcastSection
|
<PodcastSection
|
||||||
icon={<TrendingUp size={20} strokeWidth={2.5} />}
|
icon={<TrendingUp size={20} strokeWidth={2.5} />}
|
||||||
title="Tendances"
|
title="Tendances"
|
||||||
|
|||||||
@ -1,222 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
|
||||||
import { Trash2, Play, Pause, Mic, Clock, Eye, ExternalLink } from 'lucide-react'
|
|
||||||
import { supabase } from '@/lib/supabase'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
import { usePlayerStore } from '@/stores/player'
|
|
||||||
import { publicUrl } from '@/lib/storage'
|
|
||||||
import { formatDuration, timeAgo } from '@/lib/utils'
|
|
||||||
import { Button } from '@/components/ui/Button'
|
|
||||||
import type { Podcast } from '@/types'
|
|
||||||
|
|
||||||
export function MyPodcasts() {
|
|
||||||
const { user } = useAuthStore()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const play = usePlayerStore((s) => s.play)
|
|
||||||
const currentId = usePlayerStore((s) => s.current?.id)
|
|
||||||
const isPlaying = usePlayerStore((s) => s.isPlaying)
|
|
||||||
const toggle = usePlayerStore((s) => s.toggle)
|
|
||||||
|
|
||||||
const [podcasts, setPodcasts] = useState<Podcast[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
|
||||||
const [deleting, setDeleting] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!user) { navigate('/auth'); return }
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
const { data } = await supabase
|
|
||||||
.from('podcasts')
|
|
||||||
.select('*, tags:podcast_tags(tag:tags(*))')
|
|
||||||
.eq('creator_id', user!.id)
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
setPodcasts(data.map((p: any) => ({
|
|
||||||
...p,
|
|
||||||
tags: p.tags?.map((t: any) => t.tag).filter(Boolean) || [],
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
load()
|
|
||||||
}, [user, navigate])
|
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
|
||||||
setDeleting(true)
|
|
||||||
const podcast = podcasts.find(p => p.id === id)
|
|
||||||
|
|
||||||
if (podcast?.audio_url?.includes('/storage/v1/object/public/podcasts/')) {
|
|
||||||
const path = podcast.audio_url.split('/storage/v1/object/public/podcasts/')[1]
|
|
||||||
if (path) await supabase.storage.from('podcasts').remove([path])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (podcast?.cover_url?.includes('/storage/v1/object/public/covers/')) {
|
|
||||||
const path = podcast.cover_url.split('/storage/v1/object/public/covers/')[1]
|
|
||||||
if (path) await supabase.storage.from('covers').remove([path])
|
|
||||||
}
|
|
||||||
|
|
||||||
await supabase.from('podcasts').delete().eq('id', id)
|
|
||||||
setPodcasts(prev => prev.filter(p => p.id !== id))
|
|
||||||
setDeleteId(null)
|
|
||||||
setDeleting(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-3xl mx-auto">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-heading font-bold flex items-center gap-2">
|
|
||||||
<Mic size={22} />
|
|
||||||
Mes podcasts
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-text-secondary mt-1">
|
|
||||||
{podcasts.length} podcast{podcasts.length !== 1 ? 's' : ''} publié{podcasts.length !== 1 ? 's' : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button size="sm" onClick={() => navigate('/upload')}>
|
|
||||||
Publier
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{[1, 2, 3].map(i => (
|
|
||||||
<div key={i} className="bg-surface rounded-2xl border border-border p-4 animate-pulse">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-16 h-16 rounded-xl bg-border-light" />
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<div className="h-4 w-48 bg-border-light rounded" />
|
|
||||||
<div className="h-3 w-32 bg-border-light rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : podcasts.length === 0 ? (
|
|
||||||
<div className="text-center py-16 bg-surface rounded-2xl border border-border">
|
|
||||||
<Mic size={40} className="mx-auto text-text-secondary/30 mb-3" />
|
|
||||||
<p className="text-text-secondary mb-4">Vous n'avez pas encore publié de podcast.</p>
|
|
||||||
<Button onClick={() => navigate('/upload')}>Publier mon premier podcast</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{podcasts.map(podcast => {
|
|
||||||
const isActive = currentId === podcast.id
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={podcast.id}
|
|
||||||
className="bg-surface rounded-2xl border border-border hover:border-primary/20 transition-all duration-200"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 sm:gap-4 p-3 sm:p-4">
|
|
||||||
{/* Cover + play */}
|
|
||||||
<button
|
|
||||||
onClick={() => isActive ? toggle() : play(podcast)}
|
|
||||||
className="relative shrink-0 w-14 h-14 sm:w-16 sm:h-16 rounded-xl overflow-hidden cursor-pointer group"
|
|
||||||
>
|
|
||||||
{podcast.cover_url ? (
|
|
||||||
<img src={publicUrl(podcast.cover_url)} alt="" className="w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full bg-gradient-to-br from-primary/20 to-accent/10 flex items-center justify-center">
|
|
||||||
<span className="text-lg sm:text-xl font-heading font-bold text-primary/30">
|
|
||||||
{podcast.title[0]?.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="absolute inset-0 bg-black/30 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
{isActive && isPlaying
|
|
||||||
? <Pause size={18} className="text-white" fill="white" />
|
|
||||||
: <Play size={18} className="text-white ml-0.5" fill="white" />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
{isActive && isPlaying && (
|
|
||||||
<div className="absolute bottom-1 right-1 flex items-end gap-[2px] h-3">
|
|
||||||
<div className="wave-bar" /><div className="wave-bar" /><div className="wave-bar" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<Link
|
|
||||||
to={`/podcast/${podcast.id}`}
|
|
||||||
className="font-heading font-bold text-sm leading-snug line-clamp-2 hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
{podcast.title}
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center gap-3 mt-1.5 text-[11px] text-text-secondary">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Clock size={10} />
|
|
||||||
{formatDuration(podcast.duration_seconds)}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Eye size={10} />
|
|
||||||
{podcast.plays_count}
|
|
||||||
</span>
|
|
||||||
<span className="hidden sm:inline">{timeAgo(podcast.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions — always visible */}
|
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
|
||||||
<Link
|
|
||||||
to={`/podcast/${podcast.id}`}
|
|
||||||
className="p-2 rounded-xl hover:bg-primary/10 transition-colors text-text-secondary hover:text-primary"
|
|
||||||
title="Voir"
|
|
||||||
>
|
|
||||||
<ExternalLink size={16} />
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteId(podcast.id)}
|
|
||||||
className="p-2 rounded-xl hover:bg-accent/10 transition-colors text-text-secondary hover:text-accent cursor-pointer"
|
|
||||||
title="Supprimer"
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete confirmation modal */}
|
|
||||||
{deleteId && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
||||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => !deleting && setDeleteId(null)} />
|
|
||||||
<div className="relative bg-surface rounded-2xl border border-border shadow-organic-lg p-6 w-full max-w-sm">
|
|
||||||
<div className="w-12 h-12 rounded-full bg-accent/10 flex items-center justify-center mx-auto mb-4">
|
|
||||||
<Trash2 size={22} className="text-accent" />
|
|
||||||
</div>
|
|
||||||
<h3 className="font-heading font-bold text-lg text-center mb-1">Supprimer ce podcast ?</h3>
|
|
||||||
<p className="text-sm text-text-secondary text-center mb-6">
|
|
||||||
Cette action est irréversible. Le fichier audio, les commentaires et les likes seront supprimés définitivement.
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => setDeleteId(null)}
|
|
||||||
disabled={deleting}
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</Button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(deleteId)}
|
|
||||||
disabled={deleting}
|
|
||||||
className="flex-1 px-4 py-2.5 rounded-xl bg-accent text-white font-semibold text-sm hover:bg-accent/90 transition-colors disabled:opacity-50 cursor-pointer"
|
|
||||||
>
|
|
||||||
{deleting ? 'Suppression...' : 'Supprimer'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -8,7 +8,6 @@ import type { Podcast, Comment } from '@/types'
|
|||||||
import { formatDuration, timeAgo } from '@/lib/utils'
|
import { formatDuration, timeAgo } from '@/lib/utils'
|
||||||
import { Avatar } from '@/components/ui/Avatar'
|
import { Avatar } from '@/components/ui/Avatar'
|
||||||
import { Badge } from '@/components/ui/Badge'
|
import { Badge } from '@/components/ui/Badge'
|
||||||
import { publicUrl } from '@/lib/storage'
|
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
|
|
||||||
export function PodcastDetail() {
|
export function PodcastDetail() {
|
||||||
@ -109,7 +108,7 @@ export function PodcastDetail() {
|
|||||||
<div className="flex flex-col sm:flex-row gap-6">
|
<div className="flex flex-col sm:flex-row gap-6">
|
||||||
<div className="w-full sm:w-48 shrink-0">
|
<div className="w-full sm:w-48 shrink-0">
|
||||||
{podcast.cover_url ? (
|
{podcast.cover_url ? (
|
||||||
<img src={publicUrl(podcast.cover_url)} alt="" className="w-full aspect-square rounded-2xl object-cover shadow-md" />
|
<img src={podcast.cover_url} alt="" className="w-full aspect-square rounded-2xl object-cover shadow-md" />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full aspect-square rounded-2xl bg-gradient-to-br from-primary/15 to-primary/5 flex items-center justify-center">
|
<div className="w-full aspect-square rounded-2xl bg-gradient-to-br from-primary/15 to-primary/5 flex items-center justify-center">
|
||||||
<span className="text-6xl font-heading font-bold text-primary/25">{podcast.title[0]?.toUpperCase()}</span>
|
<span className="text-6xl font-heading font-bold text-primary/25">{podcast.title[0]?.toUpperCase()}</span>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Settings as SettingsIcon, LogOut, CreditCard, Crown, Mic, ChevronRight } from 'lucide-react'
|
import { Settings as SettingsIcon, LogOut, CreditCard, Crown } from 'lucide-react'
|
||||||
import { supabase } from '@/lib/supabase'
|
import { supabase } from '@/lib/supabase'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { Avatar } from '@/components/ui/Avatar'
|
import { Avatar } from '@/components/ui/Avatar'
|
||||||
@ -70,20 +70,6 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Link
|
|
||||||
to="/my-podcasts"
|
|
||||||
className="flex items-center gap-4 bg-surface rounded-2xl border border-border p-5 hover:border-primary/30 transition-all group"
|
|
||||||
>
|
|
||||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center shrink-0">
|
|
||||||
<Mic size={18} className="text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="font-heading font-bold text-sm">Mes podcasts</p>
|
|
||||||
<p className="text-xs text-text-secondary">Gérer, modifier ou supprimer vos publications</p>
|
|
||||||
</div>
|
|
||||||
<ChevronRight size={18} className="text-text-secondary group-hover:text-primary transition-colors" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="bg-surface rounded-2xl border border-border p-6">
|
<div className="bg-surface rounded-2xl border border-border p-6">
|
||||||
<h2 className="font-heading font-bold mb-3 flex items-center gap-2">
|
<h2 className="font-heading font-bold mb-3 flex items-center gap-2">
|
||||||
<Crown size={18} />
|
<Crown size={18} />
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Upload as UploadIcon, Music, X, Image, Mic, Link2, Loader2, Sparkles, Circle, Square, MicOff, ShieldAlert } from 'lucide-react'
|
import { Upload as UploadIcon, Music, X, Image, Mic, Link2, Loader2, Sparkles, Circle, Square } from 'lucide-react'
|
||||||
import { supabase } from '@/lib/supabase'
|
import { supabase } from '@/lib/supabase'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
@ -15,7 +15,6 @@ interface OEmbedData {
|
|||||||
duration: number
|
duration: number
|
||||||
audioUrl: string
|
audioUrl: string
|
||||||
platform: string
|
platform: string
|
||||||
author: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractVideoId(url: string): { platform: string; id: string } | null {
|
function extractVideoId(url: string): { platform: string; id: string } | null {
|
||||||
@ -37,101 +36,38 @@ function extractVideoId(url: string): { platform: string; id: string } | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse ISO 8601 duration (PT1H2M30S) to seconds
|
|
||||||
function parseDuration(iso: string): number {
|
|
||||||
const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/)
|
|
||||||
if (!match) return 0
|
|
||||||
const h = parseInt(match[1] || '0')
|
|
||||||
const m = parseInt(match[2] || '0')
|
|
||||||
const s = parseInt(match[3] || '0')
|
|
||||||
return h * 3600 + m * 60 + s
|
|
||||||
}
|
|
||||||
|
|
||||||
const YT_API_KEY = import.meta.env.VITE_YOUTUBE_API_KEY || ''
|
|
||||||
|
|
||||||
async function fetchVideoMeta(url: string): Promise<OEmbedData | null> {
|
async function fetchVideoMeta(url: string): Promise<OEmbedData | null> {
|
||||||
const info = extractVideoId(url)
|
const info = extractVideoId(url)
|
||||||
if (!info) return null
|
if (!info) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (info.platform === 'youtube') {
|
if (info.platform === 'youtube') {
|
||||||
// Use YouTube Data API v3 if key is available
|
|
||||||
if (YT_API_KEY) {
|
|
||||||
const apiUrl = `https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails,statistics&id=${info.id}&key=${YT_API_KEY}`
|
|
||||||
const res = await fetch(apiUrl)
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
const video = data.items?.[0]
|
|
||||||
if (video) {
|
|
||||||
const snippet = video.snippet || {}
|
|
||||||
const contentDetails = video.contentDetails || {}
|
|
||||||
const stats = video.statistics || {}
|
|
||||||
// Best thumbnail: maxres > standard > high > medium > default
|
|
||||||
const thumbs = snippet.thumbnails || {}
|
|
||||||
const thumb = thumbs.maxres?.url || thumbs.standard?.url || thumbs.high?.url || thumbs.medium?.url || thumbs.default?.url || ''
|
|
||||||
const duration = parseDuration(contentDetails.duration || '')
|
|
||||||
const viewCount = parseInt(stats.viewCount || '0')
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: snippet.title || '',
|
|
||||||
description: snippet.description?.slice(0, 500) || '',
|
|
||||||
thumbnail: thumb,
|
|
||||||
duration,
|
|
||||||
audioUrl: url,
|
|
||||||
platform: 'YouTube',
|
|
||||||
author: snippet.channelTitle || '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to oEmbed (no duration but basic metadata)
|
|
||||||
const oembedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${info.id}&format=json`
|
const oembedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${info.id}&format=json`
|
||||||
const res = await fetch(oembedUrl)
|
const res = await fetch(oembedUrl)
|
||||||
if (!res.ok) return null
|
if (!res.ok) return null
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
return {
|
return {
|
||||||
title: data.title || '',
|
title: data.title || '',
|
||||||
description: data.author_name ? `Par ${data.author_name}` : '',
|
description: '',
|
||||||
thumbnail: `https://img.youtube.com/vi/${info.id}/maxresdefault.jpg`,
|
thumbnail: `https://img.youtube.com/vi/${info.id}/hqdefault.jpg`,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
audioUrl: url,
|
audioUrl: url,
|
||||||
platform: 'YouTube',
|
platform: 'YouTube',
|
||||||
author: data.author_name || '',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (info.platform === 'dailymotion') {
|
if (info.platform === 'dailymotion') {
|
||||||
// Dailymotion public Data API — returns duration, description, tags, author
|
const oembedUrl = `https://www.dailymotion.com/services/oembed?url=https://www.dailymotion.com/video/${info.id}&format=json`
|
||||||
const apiUrl = `https://api.dailymotion.com/video/${info.id}?fields=title,description,duration,thumbnail_720_url,owner.screenname,tags,created_time`
|
const res = await fetch(oembedUrl)
|
||||||
const res = await fetch(apiUrl)
|
if (!res.ok) return null
|
||||||
if (!res.ok) {
|
|
||||||
// Fallback to oEmbed
|
|
||||||
const oembedUrl = `https://www.dailymotion.com/services/oembed?url=https://www.dailymotion.com/video/${info.id}&format=json`
|
|
||||||
const oeRes = await fetch(oembedUrl)
|
|
||||||
if (!oeRes.ok) return null
|
|
||||||
const oeData = await oeRes.json()
|
|
||||||
return {
|
|
||||||
title: oeData.title || '',
|
|
||||||
description: '',
|
|
||||||
thumbnail: oeData.thumbnail_url || '',
|
|
||||||
duration: 0,
|
|
||||||
audioUrl: url,
|
|
||||||
platform: 'Dailymotion',
|
|
||||||
author: oeData.author_name || '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
const owner = data.owner?.screenname || ''
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: data.title || '',
|
title: data.title || '',
|
||||||
description: data.description || (owner ? `Par ${owner}` : ''),
|
description: '',
|
||||||
thumbnail: data.thumbnail_720_url || `https://www.dailymotion.com/thumbnail/video/${info.id}`,
|
thumbnail: data.thumbnail_url || `https://www.dailymotion.com/thumbnail/video/${info.id}`,
|
||||||
duration: data.duration || 0,
|
duration: 0,
|
||||||
audioUrl: url,
|
audioUrl: url,
|
||||||
platform: 'Dailymotion',
|
platform: 'Dailymotion',
|
||||||
author: owner,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,19 +76,13 @@ async function fetchVideoMeta(url: string): Promise<OEmbedData | null> {
|
|||||||
const res = await fetch(oembedUrl)
|
const res = await fetch(oembedUrl)
|
||||||
if (!res.ok) return null
|
if (!res.ok) return null
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
// Extract duration from the iframe embed (SoundCloud embeds sometimes include it)
|
|
||||||
// Parse the HTML to get the widget URL for potential API calls
|
|
||||||
const author = data.author_name || ''
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: data.title || '',
|
title: data.title || '',
|
||||||
description: data.description || (author ? `Par ${author}` : ''),
|
description: data.description || '',
|
||||||
thumbnail: data.thumbnail_url || '',
|
thumbnail: data.thumbnail_url || '',
|
||||||
duration: 0,
|
duration: 0,
|
||||||
audioUrl: url,
|
audioUrl: url,
|
||||||
platform: 'SoundCloud',
|
platform: 'SoundCloud',
|
||||||
author,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@ -184,7 +114,6 @@ export function Upload() {
|
|||||||
// Recording mode
|
// Recording mode
|
||||||
const [isRecording, setIsRecording] = useState(false)
|
const [isRecording, setIsRecording] = useState(false)
|
||||||
const [recordingTime, setRecordingTime] = useState(0)
|
const [recordingTime, setRecordingTime] = useState(0)
|
||||||
const [micPermission, setMicPermission] = useState<'granted' | 'denied' | 'prompt' | 'checking'>('checking')
|
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||||
const chunksRef = useRef<Blob[]>([])
|
const chunksRef = useRef<Blob[]>([])
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
@ -193,25 +122,6 @@ export function Upload() {
|
|||||||
const [externalUrl, setExternalUrl] = useState('')
|
const [externalUrl, setExternalUrl] = useState('')
|
||||||
const [fetching, setFetching] = useState(false)
|
const [fetching, setFetching] = useState(false)
|
||||||
const [platform, setPlatform] = useState('')
|
const [platform, setPlatform] = useState('')
|
||||||
const [author, setAuthor] = useState('')
|
|
||||||
|
|
||||||
// Check microphone permission when entering original mode
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode !== 'original') return
|
|
||||||
let permStatus: PermissionStatus | null = null
|
|
||||||
async function checkMicPermission() {
|
|
||||||
try {
|
|
||||||
permStatus = await navigator.permissions.query({ name: 'microphone' as PermissionName })
|
|
||||||
setMicPermission(permStatus.state as 'granted' | 'denied' | 'prompt')
|
|
||||||
permStatus.onchange = () => setMicPermission(permStatus!.state as 'granted' | 'denied' | 'prompt')
|
|
||||||
} catch {
|
|
||||||
// Permissions API not supported or blocked by Permissions Policy — assume prompt
|
|
||||||
setMicPermission('prompt')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
checkMicPermission()
|
|
||||||
return () => { if (permStatus) permStatus.onchange = null }
|
|
||||||
}, [mode])
|
|
||||||
|
|
||||||
// Cleanup recording on unmount
|
// Cleanup recording on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -225,10 +135,8 @@ export function Upload() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
async function startRecording() {
|
async function startRecording() {
|
||||||
setError('')
|
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
setMicPermission('granted')
|
|
||||||
const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' })
|
const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' })
|
||||||
mediaRecorderRef.current = mediaRecorder
|
mediaRecorderRef.current = mediaRecorder
|
||||||
chunksRef.current = []
|
chunksRef.current = []
|
||||||
@ -253,16 +161,8 @@ export function Upload() {
|
|||||||
setIsRecording(true)
|
setIsRecording(true)
|
||||||
setRecordingTime(0)
|
setRecordingTime(0)
|
||||||
timerRef.current = setInterval(() => setRecordingTime(t => t + 1), 1000)
|
timerRef.current = setInterval(() => setRecordingTime(t => t + 1), 1000)
|
||||||
} catch (err) {
|
} catch {
|
||||||
const e = err as DOMException
|
setError('Impossible d\'acceder au microphone. Verifiez les permissions de votre navigateur.')
|
||||||
if (e.name === 'NotAllowedError') {
|
|
||||||
setMicPermission('denied')
|
|
||||||
setError('Acces au microphone refuse. Autorisez le microphone dans les parametres de votre navigateur puis rechargez la page.')
|
|
||||||
} else if (e.name === 'NotFoundError') {
|
|
||||||
setError('Aucun microphone detecte. Verifiez qu\'un micro est branche a votre appareil.')
|
|
||||||
} else {
|
|
||||||
setError('Impossible d\'acceder au microphone. Verifiez les permissions de votre navigateur.')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -318,7 +218,6 @@ export function Upload() {
|
|||||||
if (meta.description && !description) setDescription(meta.description)
|
if (meta.description && !description) setDescription(meta.description)
|
||||||
if (meta.thumbnail) setCoverPreview(meta.thumbnail)
|
if (meta.thumbnail) setCoverPreview(meta.thumbnail)
|
||||||
if (meta.duration) setDuration(meta.duration)
|
if (meta.duration) setDuration(meta.duration)
|
||||||
if (meta.author) setAuthor(meta.author)
|
|
||||||
setPlatform(meta.platform)
|
setPlatform(meta.platform)
|
||||||
setFetching(false)
|
setFetching(false)
|
||||||
}
|
}
|
||||||
@ -472,7 +371,6 @@ export function Upload() {
|
|||||||
setDuration(0)
|
setDuration(0)
|
||||||
setError('')
|
setError('')
|
||||||
setPlatform('')
|
setPlatform('')
|
||||||
setAuthor('')
|
|
||||||
}}
|
}}
|
||||||
className="text-sm text-text-secondary hover:text-text transition-colors cursor-pointer"
|
className="text-sm text-text-secondary hover:text-text transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
@ -540,42 +438,22 @@ export function Upload() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Record button */}
|
{/* Record button */}
|
||||||
{micPermission === 'denied' ? (
|
<button
|
||||||
<div className="w-full rounded-2xl border-2 border-accent/20 bg-accent/[0.03] p-5">
|
type="button"
|
||||||
<div className="flex items-center gap-4">
|
onClick={startRecording}
|
||||||
<div className="w-12 h-12 rounded-2xl bg-gray-200 flex items-center justify-center shrink-0">
|
className="w-full group relative overflow-hidden rounded-2xl border-2 border-border hover:border-accent/30 bg-surface p-5 text-left transition-all duration-300 hover:shadow-[0_8px_30px_rgba(232,96,76,0.1)] cursor-pointer"
|
||||||
<MicOff size={20} className="text-text-secondary" />
|
>
|
||||||
</div>
|
<div className="absolute top-0 right-0 w-24 h-24 bg-gradient-to-bl from-accent/[0.06] to-transparent rounded-bl-full transition-all duration-300 group-hover:w-32 group-hover:h-32" />
|
||||||
<div>
|
<div className="relative z-10 flex items-center gap-4">
|
||||||
<p className="font-heading font-bold text-text-secondary">Microphone bloque</p>
|
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-accent to-[#D04B3A] flex items-center justify-center shadow-[0_4px_16px_rgba(232,96,76,0.25)] transition-transform duration-300 group-hover:scale-110">
|
||||||
<p className="text-xs text-text-secondary mt-0.5">
|
<Circle size={20} className="text-white" fill="white" />
|
||||||
Cliquez sur l'icone <ShieldAlert size={12} className="inline -mt-0.5" /> dans la barre d'adresse de votre navigateur pour autoriser l'acces au microphone, puis rechargez la page.
|
</div>
|
||||||
</p>
|
<div>
|
||||||
</div>
|
<p className="font-heading font-bold">Enregistrer directement</p>
|
||||||
|
<p className="text-xs text-text-secondary">Utilisez votre micro pour enregistrer un podcast</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={startRecording}
|
|
||||||
className="w-full group relative overflow-hidden rounded-2xl border-2 border-border hover:border-accent/30 bg-surface p-5 text-left transition-all duration-300 hover:shadow-[0_8px_30px_rgba(232,96,76,0.1)] cursor-pointer"
|
|
||||||
>
|
|
||||||
<div className="absolute top-0 right-0 w-24 h-24 bg-gradient-to-bl from-accent/[0.06] to-transparent rounded-bl-full transition-all duration-300 group-hover:w-32 group-hover:h-32" />
|
|
||||||
<div className="relative z-10 flex items-center gap-4">
|
|
||||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-accent to-[#D04B3A] flex items-center justify-center shadow-[0_4px_16px_rgba(232,96,76,0.25)] transition-transform duration-300 group-hover:scale-110">
|
|
||||||
<Circle size={20} className="text-white" fill="white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-heading font-bold">Enregistrer directement</p>
|
|
||||||
<p className="text-xs text-text-secondary">
|
|
||||||
{micPermission === 'granted'
|
|
||||||
? 'Votre micro est pret — cliquez pour commencer'
|
|
||||||
: 'Votre navigateur demandera l\'acces au microphone'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-3 p-4 bg-primary/5 rounded-xl border border-primary/20">
|
<div className="flex items-center gap-3 p-4 bg-primary/5 rounded-xl border border-primary/20">
|
||||||
@ -620,23 +498,14 @@ export function Upload() {
|
|||||||
|
|
||||||
{platform && coverPreview && (
|
{platform && coverPreview && (
|
||||||
<div className="flex items-center gap-3 p-3 bg-mint/5 rounded-xl border border-mint/20">
|
<div className="flex items-center gap-3 p-3 bg-mint/5 rounded-xl border border-mint/20">
|
||||||
<img src={coverPreview} alt="" className="w-16 h-16 rounded-lg object-cover shrink-0" />
|
<img src={coverPreview} alt="" className="w-16 h-16 rounded-lg object-cover" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium truncate">{title || 'Sans titre'}</p>
|
<p className="text-sm font-medium truncate">{title || 'Sans titre'}</p>
|
||||||
<div className="flex items-center gap-2 mt-0.5 text-xs text-text-secondary">
|
<p className="text-xs text-text-secondary">via {platform}</p>
|
||||||
{author && <span className="truncate">{author}</span>}
|
|
||||||
{author && duration > 0 && <span>·</span>}
|
|
||||||
{duration > 0 && <span className="shrink-0">{Math.floor(duration / 60)}:{(duration % 60).toString().padStart(2, '0')}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 mt-1">
|
|
||||||
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-mint/10 text-mint">
|
|
||||||
{platform}
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-primary/10 text-primary">
|
|
||||||
Auto-detecte
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-mint/10 text-mint shrink-0">
|
||||||
|
Auto-detecte
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,68 +1,19 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { Podcast } from '@/types'
|
import type { Podcast } from '@/types'
|
||||||
import { isExternalUrl, getEmbedInfo } from '@/lib/embed'
|
import { isExternalUrl, getEmbedInfo } from '@/lib/embed'
|
||||||
import { publicUrl } from '@/lib/storage'
|
|
||||||
import { supabase } from '@/lib/supabase'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
// ──── YouTube IFrame API ────
|
// Hidden iframe manager for external content
|
||||||
declare global {
|
let hiddenIframe: HTMLIFrameElement | null = null
|
||||||
interface Window {
|
|
||||||
YT: any
|
function destroyIframe() {
|
||||||
onYouTubeIframeAPIReady: () => void
|
if (hiddenIframe) {
|
||||||
|
hiddenIframe.remove()
|
||||||
|
hiddenIframe = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let ytApiReady = false
|
|
||||||
let ytApiCallbacks: (() => void)[] = []
|
|
||||||
|
|
||||||
function loadYouTubeAPI(): Promise<void> {
|
|
||||||
if (ytApiReady) return Promise.resolve()
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (document.querySelector('script[src*="youtube.com/iframe_api"]')) {
|
|
||||||
ytApiCallbacks.push(resolve)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ytApiCallbacks.push(resolve)
|
|
||||||
const tag = document.createElement('script')
|
|
||||||
tag.src = 'https://www.youtube.com/iframe_api'
|
|
||||||
document.head.appendChild(tag)
|
|
||||||
window.onYouTubeIframeAPIReady = () => {
|
|
||||||
ytApiReady = true
|
|
||||||
ytApiCallbacks.forEach((cb) => cb())
|
|
||||||
ytApiCallbacks = []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ──── External player managers ────
|
|
||||||
let ytPlayer: any = null
|
|
||||||
let ytContainer: HTMLDivElement | null = null
|
|
||||||
let progressInterval: ReturnType<typeof setInterval> | null = null
|
|
||||||
let hiddenIframe: HTMLIFrameElement | null = null
|
|
||||||
|
|
||||||
function clearProgressInterval() {
|
|
||||||
if (progressInterval) { clearInterval(progressInterval); progressInterval = null }
|
|
||||||
}
|
|
||||||
|
|
||||||
function destroyYtPlayer() {
|
|
||||||
clearProgressInterval()
|
|
||||||
try { ytPlayer?.destroy() } catch { /* ignore */ }
|
|
||||||
ytPlayer = null
|
|
||||||
if (ytContainer) { ytContainer.remove(); ytContainer = null }
|
|
||||||
}
|
|
||||||
|
|
||||||
function destroyIframe() {
|
|
||||||
if (hiddenIframe) { hiddenIframe.remove(); hiddenIframe = null }
|
|
||||||
}
|
|
||||||
|
|
||||||
function destroyAll() {
|
|
||||||
destroyYtPlayer()
|
|
||||||
destroyIframe()
|
|
||||||
}
|
|
||||||
|
|
||||||
function createHiddenIframe(embedUrl: string) {
|
function createHiddenIframe(embedUrl: string) {
|
||||||
destroyAll()
|
destroyIframe()
|
||||||
const iframe = document.createElement('iframe')
|
const iframe = document.createElement('iframe')
|
||||||
iframe.src = embedUrl
|
iframe.src = embedUrl
|
||||||
iframe.allow = 'autoplay; encrypted-media'
|
iframe.allow = 'autoplay; encrypted-media'
|
||||||
@ -71,42 +22,6 @@ function createHiddenIframe(embedUrl: string) {
|
|||||||
hiddenIframe = iframe
|
hiddenIframe = iframe
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──── Progress persistence ────
|
|
||||||
let saveTimeout: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
async function saveProgressNow(podcastId: string, seconds: number, duration: number, markCompleted = false): Promise<void> {
|
|
||||||
if (saveTimeout) { clearTimeout(saveTimeout); saveTimeout = null }
|
|
||||||
const userId = useAuthStore.getState().user?.id
|
|
||||||
if (!userId || seconds < 1) return
|
|
||||||
const completed = markCompleted || (duration > 0 && seconds / duration > 0.95)
|
|
||||||
const { error } = await supabase.from('listen_history').upsert(
|
|
||||||
{ user_id: userId, podcast_id: podcastId, progress_seconds: Math.floor(seconds), completed, listened_at: new Date().toISOString() },
|
|
||||||
{ onConflict: 'user_id,podcast_id' }
|
|
||||||
)
|
|
||||||
if (error) console.error('[player] save progress failed:', error)
|
|
||||||
else console.log('[player] saved progress:', podcastId, Math.floor(seconds), 'seconds')
|
|
||||||
}
|
|
||||||
|
|
||||||
function debouncedSaveProgress(podcastId: string, seconds: number, duration: number) {
|
|
||||||
if (saveTimeout) clearTimeout(saveTimeout)
|
|
||||||
saveTimeout = setTimeout(() => saveProgressNow(podcastId, seconds, duration), 5000)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchSavedProgress(podcastId: string): Promise<number> {
|
|
||||||
const userId = useAuthStore.getState().user?.id
|
|
||||||
if (!userId) return 0
|
|
||||||
const { data } = await supabase
|
|
||||||
.from('listen_history')
|
|
||||||
.select('progress_seconds')
|
|
||||||
.eq('user_id', userId)
|
|
||||||
.eq('podcast_id', podcastId)
|
|
||||||
.maybeSingle()
|
|
||||||
const saved = data?.progress_seconds || 0
|
|
||||||
console.log('[player] fetched progress:', podcastId, saved, 'seconds, data:', data)
|
|
||||||
return saved > 5 ? saved : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// ──── Store ────
|
|
||||||
interface PlayerState {
|
interface PlayerState {
|
||||||
current: Podcast | null
|
current: Podcast | null
|
||||||
isPlaying: boolean
|
isPlaying: boolean
|
||||||
@ -135,19 +50,13 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
audio: null,
|
audio: null,
|
||||||
|
|
||||||
play: (podcast) => {
|
play: (podcast) => {
|
||||||
const { audio, current, volume } = get()
|
const { audio, current } = get()
|
||||||
const external = isExternalUrl(podcast.audio_url)
|
const external = isExternalUrl(podcast.audio_url)
|
||||||
const embed = external ? getEmbedInfo(podcast.audio_url) : null
|
|
||||||
|
|
||||||
// Resume same podcast (already loaded)
|
// Resume same podcast
|
||||||
if (current?.id === podcast.id) {
|
if (current?.id === podcast.id) {
|
||||||
if (external && embed?.platform === 'youtube' && ytPlayer) {
|
|
||||||
try { ytPlayer.playVideo() } catch { /* ignore */ }
|
|
||||||
startYtProgressTracking()
|
|
||||||
set({ isPlaying: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (external) {
|
if (external) {
|
||||||
|
const embed = getEmbedInfo(podcast.audio_url)
|
||||||
if (embed) createHiddenIframe(embed.embedUrl)
|
if (embed) createHiddenIframe(embed.embedUrl)
|
||||||
set({ isPlaying: true })
|
set({ isPlaying: true })
|
||||||
return
|
return
|
||||||
@ -159,106 +68,31 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop & save previous
|
// Stop previous
|
||||||
const prev = get().current
|
if (audio) {
|
||||||
const savePrevPromise = prev
|
audio.pause()
|
||||||
? saveProgressNow(prev.id, get().progress, get().duration)
|
audio.removeAttribute('src')
|
||||||
: Promise.resolve()
|
|
||||||
if (audio) { audio.pause(); audio.removeAttribute('src') }
|
|
||||||
destroyAll()
|
|
||||||
|
|
||||||
// ── YouTube: use IFrame API for full control ──
|
|
||||||
if (external && embed?.platform === 'youtube') {
|
|
||||||
set({ audio: null, current: podcast, isPlaying: false, isExternal: true, progress: 0, duration: podcast.duration_seconds || 0 })
|
|
||||||
|
|
||||||
// Wait for previous save to complete, then fetch saved progress
|
|
||||||
savePrevPromise.then(() => fetchSavedProgress(podcast.id)).then((savedTime) => {
|
|
||||||
loadYouTubeAPI().then(() => {
|
|
||||||
ytContainer = document.createElement('div')
|
|
||||||
ytContainer.style.cssText = 'position:fixed;width:1px;height:1px;left:-10px;top:-10px;opacity:0;pointer-events:none;'
|
|
||||||
const playerDiv = document.createElement('div')
|
|
||||||
playerDiv.id = 'yt-player-' + Date.now()
|
|
||||||
ytContainer.appendChild(playerDiv)
|
|
||||||
document.body.appendChild(ytContainer)
|
|
||||||
|
|
||||||
ytPlayer = new window.YT.Player(playerDiv.id, {
|
|
||||||
videoId: embed.id,
|
|
||||||
playerVars: {
|
|
||||||
autoplay: 1,
|
|
||||||
controls: 0,
|
|
||||||
disablekb: 1,
|
|
||||||
fs: 0,
|
|
||||||
modestbranding: 1,
|
|
||||||
rel: 0,
|
|
||||||
start: savedTime > 0 ? Math.floor(savedTime) : undefined,
|
|
||||||
},
|
|
||||||
events: {
|
|
||||||
onReady: (e: any) => {
|
|
||||||
e.target.setVolume(volume * 100)
|
|
||||||
e.target.playVideo()
|
|
||||||
const dur = e.target.getDuration()
|
|
||||||
if (dur > 0) set({ duration: dur })
|
|
||||||
if (savedTime > 0) {
|
|
||||||
e.target.seekTo(savedTime, true)
|
|
||||||
set({ progress: savedTime })
|
|
||||||
}
|
|
||||||
set({ isPlaying: true })
|
|
||||||
startYtProgressTracking()
|
|
||||||
},
|
|
||||||
onStateChange: (e: any) => {
|
|
||||||
if (e.data === 0) {
|
|
||||||
// Ended
|
|
||||||
clearProgressInterval()
|
|
||||||
const podId = usePlayerStore.getState().current?.id
|
|
||||||
if (podId) saveProgressNow(podId, e.target.getDuration(), e.target.getDuration(), true)
|
|
||||||
set({ isPlaying: false, progress: 0 })
|
|
||||||
} else if (e.data === 1) {
|
|
||||||
const dur = e.target.getDuration()
|
|
||||||
if (dur > 0) set({ duration: dur })
|
|
||||||
set({ isPlaying: true })
|
|
||||||
startYtProgressTracking()
|
|
||||||
} else if (e.data === 2) {
|
|
||||||
clearProgressInterval()
|
|
||||||
// Save on pause
|
|
||||||
const state = usePlayerStore.getState()
|
|
||||||
if (state.current) saveProgressNow(state.current.id, state.progress, state.duration)
|
|
||||||
set({ isPlaying: false })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
destroyIframe()
|
||||||
|
|
||||||
// ── Other external (Dailymotion, SoundCloud): hidden iframe ──
|
// External: play via hidden iframe
|
||||||
if (external && embed) {
|
if (external) {
|
||||||
createHiddenIframe(embed.embedUrl)
|
const embed = getEmbedInfo(podcast.audio_url)
|
||||||
|
if (embed) createHiddenIframe(embed.embedUrl)
|
||||||
set({ audio: null, current: podcast, isPlaying: true, isExternal: true, progress: 0, duration: podcast.duration_seconds || 0 })
|
set({ audio: null, current: podcast, isPlaying: true, isExternal: true, progress: 0, duration: podcast.duration_seconds || 0 })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Native audio ──
|
const newAudio = new Audio(podcast.audio_url)
|
||||||
const newAudio = new Audio(publicUrl(podcast.audio_url))
|
newAudio.volume = get().volume
|
||||||
newAudio.volume = volume
|
|
||||||
|
|
||||||
newAudio.addEventListener('timeupdate', () => {
|
newAudio.addEventListener('timeupdate', () => {
|
||||||
set({ progress: newAudio.currentTime })
|
set({ progress: newAudio.currentTime })
|
||||||
debouncedSaveProgress(podcast.id, newAudio.currentTime, newAudio.duration || 0)
|
|
||||||
})
|
})
|
||||||
newAudio.addEventListener('loadedmetadata', () => {
|
newAudio.addEventListener('loadedmetadata', () => {
|
||||||
set({ duration: newAudio.duration })
|
set({ duration: newAudio.duration })
|
||||||
// Wait for previous save, then restore saved position
|
|
||||||
savePrevPromise.then(() => fetchSavedProgress(podcast.id)).then((savedTime) => {
|
|
||||||
if (savedTime > 0 && savedTime < newAudio.duration - 5) {
|
|
||||||
newAudio.currentTime = savedTime
|
|
||||||
set({ progress: savedTime })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
newAudio.addEventListener('ended', () => {
|
newAudio.addEventListener('ended', () => {
|
||||||
saveProgressNow(podcast.id, newAudio.duration, newAudio.duration, true)
|
|
||||||
set({ isPlaying: false, progress: 0 })
|
set({ isPlaying: false, progress: 0 })
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -267,39 +101,20 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
toggle: () => {
|
toggle: () => {
|
||||||
const { audio, isPlaying, isExternal, current, progress, duration } = get()
|
const { audio, isPlaying, isExternal, current } = get()
|
||||||
|
|
||||||
if (isExternal) {
|
if (isExternal) {
|
||||||
const embed = current ? getEmbedInfo(current.audio_url) : null
|
|
||||||
|
|
||||||
if (embed?.platform === 'youtube' && ytPlayer) {
|
|
||||||
try {
|
|
||||||
if (isPlaying) {
|
|
||||||
ytPlayer.pauseVideo()
|
|
||||||
clearProgressInterval()
|
|
||||||
if (current) saveProgressNow(current.id, progress, duration)
|
|
||||||
} else {
|
|
||||||
ytPlayer.playVideo()
|
|
||||||
startYtProgressTracking()
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
set({ isPlaying: !isPlaying })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
destroyIframe()
|
destroyIframe()
|
||||||
} else if (embed) {
|
} else {
|
||||||
createHiddenIframe(embed.embedUrl)
|
const embed = current ? getEmbedInfo(current.audio_url) : null
|
||||||
|
if (embed) createHiddenIframe(embed.embedUrl)
|
||||||
}
|
}
|
||||||
set({ isPlaying: !isPlaying })
|
set({ isPlaying: !isPlaying })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!audio) return
|
if (!audio) return
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
audio.pause()
|
audio.pause()
|
||||||
if (current) saveProgressNow(current.id, progress, duration)
|
|
||||||
} else {
|
} else {
|
||||||
audio.play()
|
audio.play()
|
||||||
}
|
}
|
||||||
@ -307,35 +122,14 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
pause: () => {
|
pause: () => {
|
||||||
const { isExternal, current, progress, duration } = get()
|
const { isExternal } = get()
|
||||||
if (current) saveProgressNow(current.id, progress, duration)
|
if (isExternal) destroyIframe()
|
||||||
|
|
||||||
if (isExternal) {
|
|
||||||
const embed = current ? getEmbedInfo(current.audio_url) : null
|
|
||||||
if (embed?.platform === 'youtube' && ytPlayer) {
|
|
||||||
try { ytPlayer.pauseVideo() } catch { /* ignore */ }
|
|
||||||
clearProgressInterval()
|
|
||||||
} else {
|
|
||||||
destroyIframe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
get().audio?.pause()
|
get().audio?.pause()
|
||||||
set({ isPlaying: false })
|
set({ isPlaying: false })
|
||||||
},
|
},
|
||||||
|
|
||||||
seek: (time) => {
|
seek: (time) => {
|
||||||
const { audio, isExternal, current } = get()
|
const { audio } = get()
|
||||||
|
|
||||||
if (isExternal) {
|
|
||||||
const embed = current ? getEmbedInfo(current.audio_url) : null
|
|
||||||
if (embed?.platform === 'youtube' && ytPlayer) {
|
|
||||||
try { ytPlayer.seekTo(time, true) } catch { /* ignore */ }
|
|
||||||
set({ progress: time })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!audio) return
|
if (!audio) return
|
||||||
audio.currentTime = time
|
audio.currentTime = time
|
||||||
set({ progress: time })
|
set({ progress: time })
|
||||||
@ -344,27 +138,9 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
setVolume: (vol) => {
|
setVolume: (vol) => {
|
||||||
const { audio } = get()
|
const { audio } = get()
|
||||||
if (audio) audio.volume = vol
|
if (audio) audio.volume = vol
|
||||||
if (ytPlayer) try { ytPlayer.setVolume(vol * 100) } catch { /* ignore */ }
|
|
||||||
set({ volume: vol })
|
set({ volume: vol })
|
||||||
},
|
},
|
||||||
|
|
||||||
setProgress: (progress) => set({ progress }),
|
setProgress: (progress) => set({ progress }),
|
||||||
setDuration: (duration) => set({ duration }),
|
setDuration: (duration) => set({ duration }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Track YouTube progress via polling
|
|
||||||
function startYtProgressTracking() {
|
|
||||||
clearProgressInterval()
|
|
||||||
progressInterval = setInterval(() => {
|
|
||||||
if (ytPlayer?.getCurrentTime) {
|
|
||||||
try {
|
|
||||||
const time = ytPlayer.getCurrentTime()
|
|
||||||
const dur = ytPlayer.getDuration()
|
|
||||||
usePlayerStore.setState({ progress: time })
|
|
||||||
if (dur > 0) usePlayerStore.setState({ duration: dur })
|
|
||||||
const podId = usePlayerStore.getState().current?.id
|
|
||||||
if (podId) debouncedSaveProgress(podId, time, dur)
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
-- Create storage buckets for audio files and cover images
|
|
||||||
insert into storage.buckets (id, name, public)
|
|
||||||
values
|
|
||||||
('podcasts', 'podcasts', true),
|
|
||||||
('covers', 'covers', true)
|
|
||||||
on conflict (id) do nothing;
|
|
||||||
|
|
||||||
-- Allow anyone to read files (public buckets)
|
|
||||||
create policy "Public read access on podcasts" on storage.objects
|
|
||||||
for select using (bucket_id = 'podcasts');
|
|
||||||
|
|
||||||
create policy "Public read access on covers" on storage.objects
|
|
||||||
for select using (bucket_id = 'covers');
|
|
||||||
|
|
||||||
-- Allow authenticated users to upload files
|
|
||||||
create policy "Authenticated users can upload podcasts" on storage.objects
|
|
||||||
for insert with check (bucket_id = 'podcasts' and auth.role() = 'authenticated');
|
|
||||||
|
|
||||||
create policy "Authenticated users can upload covers" on storage.objects
|
|
||||||
for insert with check (bucket_id = 'covers' and auth.role() = 'authenticated');
|
|
||||||
|
|
||||||
-- Allow users to update/delete their own files
|
|
||||||
create policy "Users can update own podcast files" on storage.objects
|
|
||||||
for update using (bucket_id = 'podcasts' and auth.uid()::text = (storage.foldername(name))[1]);
|
|
||||||
|
|
||||||
create policy "Users can delete own podcast files" on storage.objects
|
|
||||||
for delete using (bucket_id = 'podcasts' and auth.uid()::text = (storage.foldername(name))[1]);
|
|
||||||
|
|
||||||
create policy "Users can update own cover files" on storage.objects
|
|
||||||
for update using (bucket_id = 'covers' and auth.uid()::text = (storage.foldername(name))[1]);
|
|
||||||
|
|
||||||
create policy "Users can delete own cover files" on storage.objects
|
|
||||||
for delete using (bucket_id = 'covers' and auth.uid()::text = (storage.foldername(name))[1]);
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
-- Add progress tracking to listen_history
|
|
||||||
ALTER TABLE public.listen_history
|
|
||||||
ADD COLUMN IF NOT EXISTS progress_seconds integer NOT NULL DEFAULT 0,
|
|
||||||
ADD COLUMN IF NOT EXISTS completed boolean NOT NULL DEFAULT false;
|
|
||||||
|
|
||||||
-- Unique constraint for upsert (one row per user+podcast)
|
|
||||||
ALTER TABLE public.listen_history
|
|
||||||
ADD CONSTRAINT listen_history_user_podcast_unique UNIQUE (user_id, podcast_id);
|
|
||||||
|
|
||||||
-- Allow users to update their own progress
|
|
||||||
CREATE POLICY "Users can update own history" ON public.listen_history
|
|
||||||
FOR UPDATE USING (auth.uid() = user_id);
|
|
||||||
|
|
||||||
-- Allow users to delete own history
|
|
||||||
CREATE POLICY "Users can delete own history" ON public.listen_history
|
|
||||||
FOR DELETE USING (auth.uid() = user_id);
|
|
||||||
|
|
||||||
-- Index for "continue listening" query
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_listen_history_resume
|
|
||||||
ON public.listen_history(user_id, listened_at DESC)
|
|
||||||
WHERE NOT completed;
|
|
||||||
Loading…
x
Reference in New Issue
Block a user