correct player

This commit is contained in:
ordinarthur 2026-04-13 01:08:05 +02:00
parent cc4ced3076
commit 95a7e744ff
14 changed files with 889 additions and 153 deletions

View File

@ -3,7 +3,8 @@
"allow": [ "allow": [
"Bash(npm install:*)", "Bash(npm install:*)",
"WebFetch(domain:archive.org)", "WebFetch(domain:archive.org)",
"WebSearch" "WebSearch",
"WebFetch(domain:inv.nadeko.net)"
] ]
} }
} }

7
Dockerfile.dev Normal file
View File

@ -0,0 +1,7 @@
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"]

14
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,14 @@
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "5173:5173"
volumes:
- .:/app
- /app/node_modules
env_file:
- .env
environment:
- CHOKIDAR_USEPOLLING=true

View File

@ -11,6 +11,7 @@ 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()
@ -39,6 +40,7 @@ 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>

View File

@ -1,112 +1,195 @@
import { useRef } from 'react' import { useRef, useState, useCallback } 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 ? (progress / duration) * 100 : 0 const pct = duration > 0 ? (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 handleSeek(e: React.MouseEvent<HTMLDivElement>) { function startDrag(clientX: number) {
if (!seekRef.current || isExternal) return if (!canSeek) return
const rect = seekRef.current.getBoundingClientRect() setIsDragging(true)
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) setDragPct(calcPct(clientX))
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)]">
{/* Seek bar — full width, always visible */} <div className="max-w-6xl mx-auto px-4 sm:px-6">
<div {/* Main row */}
ref={seekRef} <div className="h-16 flex items-center gap-3 sm:gap-5">
className="relative h-1.5 bg-white/10 cursor-pointer group" {/* Cover + info */}
onClick={handleSeek} <div className="flex items-center gap-3 min-w-0 flex-1">
> <div className="relative shrink-0">
<div {current.cover_url ? (
className="absolute inset-y-0 left-0 bg-gradient-to-r from-primary to-[#7B6AEF] rounded-r-full transition-[width] duration-100" <img src={publicUrl(current.cover_url)} alt="" className="w-11 h-11 rounded-lg object-cover" />
style={{ width: `${pct}%` }} ) : (
/> <Avatar name={current.title} size="md" className="!rounded-lg !w-11 !h-11" />
{!isExternal && ( )}
{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">{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(duration, progress + 15))}
>
<SkipForward size={18} />
</button>
)}
</div>
{/* Time + 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">
{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>
{/* 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 <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" className="absolute left-0 right-0 rounded-full"
style={{ left: `${pct}%`, marginLeft: '-7px' }} style={{ height: '5px', background: 'rgba(255,255,255,0.15)', top: '50%', transform: 'translateY(-50%)' }}
/>
)}
</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="relative shrink-0">
{current.cover_url ? (
<img src={current.cover_url} alt="" className="w-12 h-12 rounded-lg object-cover" />
) : (
<Avatar name={current.title} size="md" className="!rounded-lg !w-12 !h-12" />
)}
{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">{current.creator?.username}</p>
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-1.5 sm:gap-3">
{!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.max(0, progress - 15))}
>
<SkipBack size={18} />
</button>
)}
<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" />} {/* Track filled */}
</button> <div
{!isExternal && ( className="absolute inset-y-0 left-0 rounded-full"
<button style={{
className="hidden sm:flex text-white/50 hover:text-white transition-colors cursor-pointer p-1.5 rounded-full hover:bg-white/10" width: `${displayPct}%`,
onClick={() => seek(Math.min(duration, progress + 15))} background: 'linear-gradient(90deg, #5B4CDB, #7B6AEF)',
> }}
<SkipForward size={18} /> />
</button> </div>
)}
</div>
{/* Time + Volume */} {/* Thumb — centered on track */}
<div className="hidden sm:flex items-center gap-3 flex-1 justify-end"> {canSeek && (
<span className="text-[11px] text-white/40 tabular-nums font-medium min-w-[5rem] text-right"> <div
{!isExternal ? `${formatDuration(progress)} / ${formatDuration(duration)}` : duration > 0 ? formatDuration(duration) : ''} className="absolute"
</span> style={{
<button left: `calc(${displayPct}% - 8px)`,
onClick={() => setVolume(volume === 0 ? 0.8 : 0)} top: '50%',
className="text-white/50 hover:text-white transition-colors cursor-pointer p-1 rounded-full hover:bg-white/10" transform: 'translateY(-50%)',
> width: '16px',
{volume === 0 ? <VolumeX size={15} /> : <Volume2 size={15} />} height: '16px',
</button> borderRadius: '50%',
<input background: '#ffffff',
type="range" boxShadow: '0 0 10px rgba(91,76,219,0.7), 0 0 0 2px rgba(255,255,255,0.3)',
min={0} transition: isDragging ? 'none' : 'left 0.1s',
max={1} zIndex: 2,
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> </div>
) )

View File

@ -2,6 +2,7 @@ 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'
@ -34,7 +35,7 @@ export function PodcastCard({ podcast }: PodcastCardProps) {
{/* Cover */} {/* Cover */}
<div className="relative aspect-[4/3]"> <div className="relative aspect-[4/3]">
{podcast.cover_url ? ( {podcast.cover_url ? (
<img src={podcast.cover_url} alt="" className="w-full h-full object-cover" /> <img src={publicUrl(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">

View File

@ -124,6 +124,59 @@ 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; }

14
src/lib/storage.ts Normal file
View File

@ -0,0 +1,14 @@
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}`
}

222
src/pages/MyPodcasts.tsx Normal file
View File

@ -0,0 +1,222 @@
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>
)
}

View File

@ -8,6 +8,7 @@ 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() {
@ -108,7 +109,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={podcast.cover_url} alt="" className="w-full aspect-square rounded-2xl object-cover shadow-md" /> <img src={publicUrl(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>

View File

@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate, Link } from 'react-router-dom'
import { Settings as SettingsIcon, LogOut, CreditCard, Crown } from 'lucide-react' import { Settings as SettingsIcon, LogOut, CreditCard, Crown, Mic, ChevronRight } 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,6 +70,20 @@ 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} />

View File

@ -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 } from 'lucide-react' import { Upload as UploadIcon, Music, X, Image, Mic, Link2, Loader2, Sparkles, Circle, Square, MicOff, ShieldAlert } 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,6 +15,7 @@ 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 {
@ -36,38 +37,101 @@ 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: '', description: data.author_name ? `Par ${data.author_name}` : '',
thumbnail: `https://img.youtube.com/vi/${info.id}/hqdefault.jpg`, thumbnail: `https://img.youtube.com/vi/${info.id}/maxresdefault.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') {
const oembedUrl = `https://www.dailymotion.com/services/oembed?url=https://www.dailymotion.com/video/${info.id}&format=json` // Dailymotion public Data API — returns duration, description, tags, author
const res = await fetch(oembedUrl) const apiUrl = `https://api.dailymotion.com/video/${info.id}?fields=title,description,duration,thumbnail_720_url,owner.screenname,tags,created_time`
if (!res.ok) return null const res = await fetch(apiUrl)
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: '', description: data.description || (owner ? `Par ${owner}` : ''),
thumbnail: data.thumbnail_url || `https://www.dailymotion.com/thumbnail/video/${info.id}`, thumbnail: data.thumbnail_720_url || `https://www.dailymotion.com/thumbnail/video/${info.id}`,
duration: 0, duration: data.duration || 0,
audioUrl: url, audioUrl: url,
platform: 'Dailymotion', platform: 'Dailymotion',
author: owner,
} }
} }
@ -76,13 +140,19 @@ 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 || '', description: data.description || (author ? `Par ${author}` : ''),
thumbnail: data.thumbnail_url || '', thumbnail: data.thumbnail_url || '',
duration: 0, duration: 0,
audioUrl: url, audioUrl: url,
platform: 'SoundCloud', platform: 'SoundCloud',
author,
} }
} }
} catch { } catch {
@ -114,6 +184,7 @@ 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)
@ -122,6 +193,25 @@ 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(() => {
@ -135,8 +225,10 @@ 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 = []
@ -161,8 +253,16 @@ 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 { } catch (err) {
setError('Impossible d\'acceder au microphone. Verifiez les permissions de votre navigateur.') const e = err as DOMException
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.')
}
} }
} }
@ -218,6 +318,7 @@ 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)
} }
@ -371,6 +472,7 @@ 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"
> >
@ -438,22 +540,42 @@ export function Upload() {
</div> </div>
{/* Record button */} {/* Record button */}
<button {micPermission === 'denied' ? (
type="button" <div className="w-full rounded-2xl border-2 border-accent/20 bg-accent/[0.03] p-5">
onClick={startRecording} <div className="flex items-center gap-4">
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="w-12 h-12 rounded-2xl bg-gray-200 flex items-center justify-center shrink-0">
> <MicOff size={20} className="text-text-secondary" />
<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"> <div>
<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="font-heading font-bold text-text-secondary">Microphone bloque</p>
<Circle size={20} className="text-white" fill="white" /> <p className="text-xs text-text-secondary mt-0.5">
</div> 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>
<p className="font-heading font-bold">Enregistrer directement</p> </div>
<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">
@ -498,14 +620,23 @@ 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" /> <img src={coverPreview} alt="" className="w-16 h-16 rounded-lg object-cover shrink-0" />
<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>
<p className="text-xs text-text-secondary">via {platform}</p> <div className="flex items-center gap-2 mt-0.5 text-xs text-text-secondary">
{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>

View File

@ -1,19 +1,66 @@
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'
// Hidden iframe manager for external content // ──── YouTube IFrame API ────
let hiddenIframe: HTMLIFrameElement | null = null declare global {
interface Window {
function destroyIframe() { YT: any
if (hiddenIframe) { onYouTubeIframeAPIReady: () => void
hiddenIframe.remove()
hiddenIframe = null
} }
} }
function createHiddenIframe(embedUrl: string) { 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() destroyIframe()
}
function createHiddenIframe(embedUrl: string) {
destroyAll()
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'
@ -22,6 +69,7 @@ function createHiddenIframe(embedUrl: string) {
hiddenIframe = iframe hiddenIframe = iframe
} }
// ──── Store ────
interface PlayerState { interface PlayerState {
current: Podcast | null current: Podcast | null
isPlaying: boolean isPlaying: boolean
@ -50,13 +98,19 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
audio: null, audio: null,
play: (podcast) => { play: (podcast) => {
const { audio, current } = get() const { audio, current, volume } = get()
const external = isExternalUrl(podcast.audio_url) const external = isExternalUrl(podcast.audio_url)
const embed = external ? getEmbedInfo(podcast.audio_url) : null
// Resume same podcast // 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
@ -69,22 +123,72 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
} }
// Stop previous // Stop previous
if (audio) { if (audio) { audio.pause(); audio.removeAttribute('src') }
audio.pause() destroyAll()
audio.removeAttribute('src')
}
destroyIframe()
// External: play via hidden iframe // ── YouTube: use IFrame API for full control ──
if (external) { if (external && embed?.platform === 'youtube') {
const embed = getEmbedInfo(podcast.audio_url) set({ audio: null, current: podcast, isPlaying: false, isExternal: true, progress: 0, duration: podcast.duration_seconds || 0 })
if (embed) createHiddenIframe(embed.embedUrl)
loadYouTubeAPI().then(() => {
// Create hidden container
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,
},
events: {
onReady: (e: any) => {
e.target.setVolume(volume * 100)
e.target.playVideo()
const dur = e.target.getDuration()
if (dur > 0) set({ duration: dur })
set({ isPlaying: true })
startYtProgressTracking()
},
onStateChange: (e: any) => {
// YT.PlayerState: 0=ENDED, 1=PLAYING, 2=PAUSED
if (e.data === 0) {
clearProgressInterval()
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()
set({ isPlaying: false })
}
},
},
})
})
return
}
// ── Other external (Dailymotion, SoundCloud): hidden iframe ──
if (external && 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
} }
const newAudio = new Audio(podcast.audio_url) // ── Native audio ──
newAudio.volume = get().volume const newAudio = new Audio(publicUrl(podcast.audio_url))
newAudio.volume = volume
newAudio.addEventListener('timeupdate', () => { newAudio.addEventListener('timeupdate', () => {
set({ progress: newAudio.currentTime }) set({ progress: newAudio.currentTime })
@ -102,16 +206,35 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
toggle: () => { toggle: () => {
const { audio, isPlaying, isExternal, current } = get() const { audio, isPlaying, isExternal, current } = get()
if (isExternal) { if (isExternal) {
const embed = current ? getEmbedInfo(current.audio_url) : null
// YouTube: proper pause/play
if (embed?.platform === 'youtube' && ytPlayer) {
try {
if (isPlaying) {
ytPlayer.pauseVideo()
clearProgressInterval()
} else {
ytPlayer.playVideo()
startYtProgressTracking()
}
} catch { /* ignore */ }
set({ isPlaying: !isPlaying })
return
}
// Other external: destroy/recreate iframe
if (isPlaying) { if (isPlaying) {
destroyIframe() destroyIframe()
} else { } else if (embed) {
const embed = current ? getEmbedInfo(current.audio_url) : null createHiddenIframe(embed.embedUrl)
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()
@ -122,14 +245,35 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
}, },
pause: () => { pause: () => {
const { isExternal } = get() const { isExternal, current } = get()
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 } = get() const { audio, isExternal, current } = get()
// YouTube seek
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
}
// Can't seek other external types
return
}
if (!audio) return if (!audio) return
audio.currentTime = time audio.currentTime = time
set({ progress: time }) set({ progress: time })
@ -138,9 +282,25 @@ 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 })
} catch { /* ignore */ }
}
}, 500)
}

View File

@ -0,0 +1,33 @@
-- 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]);