From 95a7e744ff973ff5ee8879ce0cbd37234c0e9ec3 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Mon, 13 Apr 2026 01:08:05 +0200 Subject: [PATCH] correct player --- .claude/settings.local.json | 3 +- Dockerfile.dev | 7 + docker-compose.dev.yml | 14 ++ src/App.tsx | 2 + src/components/layout/PlayerBar.tsx | 263 +++++++++++++------- src/components/podcast/PodcastCard.tsx | 3 +- src/index.css | 53 ++++ src/lib/storage.ts | 14 ++ src/pages/MyPodcasts.tsx | 222 +++++++++++++++++ src/pages/PodcastDetail.tsx | 3 +- src/pages/Settings.tsx | 18 +- src/pages/Upload.tsx | 193 +++++++++++--- src/stores/player.ts | 214 ++++++++++++++-- supabase/migrations/002_storage_buckets.sql | 33 +++ 14 files changed, 889 insertions(+), 153 deletions(-) create mode 100644 Dockerfile.dev create mode 100644 docker-compose.dev.yml create mode 100644 src/lib/storage.ts create mode 100644 src/pages/MyPodcasts.tsx create mode 100644 supabase/migrations/002_storage_buckets.sql diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 947d9b7..bc17db0 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,8 @@ "allow": [ "Bash(npm install:*)", "WebFetch(domain:archive.org)", - "WebSearch" + "WebSearch", + "WebFetch(domain:inv.nadeko.net)" ] } } diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..0e9e783 --- /dev/null +++ b/Dockerfile.dev @@ -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"] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..377276f --- /dev/null +++ b/docker-compose.dev.yml @@ -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 diff --git a/src/App.tsx b/src/App.tsx index aca875b..8d623f1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { PodcastDetail } from '@/pages/PodcastDetail' import { Profile } from '@/pages/Profile' import { Favorites } from '@/pages/Favorites' import { Settings } from '@/pages/Settings' +import { MyPodcasts } from '@/pages/MyPodcasts' export default function App() { const { setUser, fetchProfile } = useAuthStore() @@ -39,6 +40,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/src/components/layout/PlayerBar.tsx b/src/components/layout/PlayerBar.tsx index d61de41..d601f03 100644 --- a/src/components/layout/PlayerBar.tsx +++ b/src/components/layout/PlayerBar.tsx @@ -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 { usePlayerStore } from '@/stores/player' import { formatDuration } from '@/lib/utils' +import { publicUrl } from '@/lib/storage' import { Avatar } from '@/components/ui/Avatar' export function PlayerBar() { const { current, isPlaying, isExternal, progress, duration, volume, toggle, seek, setVolume } = usePlayerStore() const seekRef = useRef(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 - 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) { - if (!seekRef.current || isExternal) return - const rect = seekRef.current.getBoundingClientRect() - const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) - seek(ratio * duration) + function startDrag(clientX: number) { + if (!canSeek) return + setIsDragging(true) + setDragPct(calcPct(clientX)) } + function handleMouseDown(e: React.MouseEvent) { + if (!canSeek) return + e.preventDefault() + startDrag(e.clientX) + + const onMove = (ev: MouseEvent) => setDragPct(calcPct(ev.clientX)) + const onUp = (ev: MouseEvent) => { + setIsDragging(false) + const p = calcPct(ev.clientX) + seek((p / 100) * 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 (
- {/* Seek bar — full width, always visible */} -
-
- {!isExternal && ( +
+ {/* Main row */} +
+ {/* Cover + info */} +
+
+ {current.cover_url ? ( + + ) : ( + + )} + {isPlaying && ( +
+
+
+ )} +
+
+

{current.title}

+

{current.creator?.username}

+
+
+ + {/* Controls */} +
+ {canSeek && ( + + )} + + {canSeek && ( + + )} +
+ + {/* Time + Volume (desktop) */} +
+ + {duration > 0 + ? `${formatDuration(currentTime)} / ${formatDuration(duration)}` + : ''} + + + setVolume(parseFloat(e.target.value))} + className="w-20 accent-white" + /> +
+
+ + {/* Seek bar */} + {showBar &&
+ {/* Track background — centered vertically */}
- )} -
- -
- {/* Cover + info */} -
-
- {current.cover_url ? ( - - ) : ( - - )} - {isPlaying && ( -
-
-
- )} -
-
-

{current.title}

-

{current.creator?.username}

-
-
- - {/* Controls */} -
- {!isExternal && ( - - )} - - {!isExternal && ( - - )} -
+ {/* Track filled */} +
+
- {/* Time + Volume */} -
- - {!isExternal ? `${formatDuration(progress)} / ${formatDuration(duration)}` : duration > 0 ? formatDuration(duration) : ''} - - - setVolume(parseFloat(e.target.value))} - className="w-20 accent-white [&::-webkit-slider-thumb]:!bg-white [&::-webkit-slider-thumb]:!shadow-none" - /> -
+ {/* Thumb — centered on track */} + {canSeek && ( +
+ )} +
}
) diff --git a/src/components/podcast/PodcastCard.tsx b/src/components/podcast/PodcastCard.tsx index 1b41734..dc05ef6 100644 --- a/src/components/podcast/PodcastCard.tsx +++ b/src/components/podcast/PodcastCard.tsx @@ -2,6 +2,7 @@ import { Play, Pause, Heart, MessageCircle, Clock } from 'lucide-react' import { Link } from 'react-router-dom' import type { Podcast } from '@/types' import { formatDuration, timeAgo } from '@/lib/utils' +import { publicUrl } from '@/lib/storage' import { Badge } from '@/components/ui/Badge' import { Avatar } from '@/components/ui/Avatar' import { usePlayerStore } from '@/stores/player' @@ -34,7 +35,7 @@ export function PodcastCard({ podcast }: PodcastCardProps) { {/* Cover */}
{podcast.cover_url ? ( - + ) : (
diff --git a/src/index.css b/src/index.css index 3f2837a..513661b 100644 --- a/src/index.css +++ b/src/index.css @@ -124,6 +124,59 @@ input[type="range"]::-webkit-slider-thumb:hover { 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 */ @keyframes wave { 0%, 100% { height: 8px; } diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..284b30c --- /dev/null +++ b/src/lib/storage.ts @@ -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}` +} diff --git a/src/pages/MyPodcasts.tsx b/src/pages/MyPodcasts.tsx new file mode 100644 index 0000000..132d000 --- /dev/null +++ b/src/pages/MyPodcasts.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [deleteId, setDeleteId] = useState(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 ( +
+
+
+

+ + Mes podcasts +

+

+ {podcasts.length} podcast{podcasts.length !== 1 ? 's' : ''} publié{podcasts.length !== 1 ? 's' : ''} +

+
+ +
+ + {loading ? ( +
+ {[1, 2, 3].map(i => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ) : podcasts.length === 0 ? ( +
+ +

Vous n'avez pas encore publié de podcast.

+ +
+ ) : ( +
+ {podcasts.map(podcast => { + const isActive = currentId === podcast.id + + return ( +
+
+ {/* Cover + play */} + + + {/* Info */} +
+ + {podcast.title} + +
+ + + {formatDuration(podcast.duration_seconds)} + + + + {podcast.plays_count} + + {timeAgo(podcast.created_at)} +
+
+ + {/* Actions — always visible */} +
+ + + + +
+
+
+ ) + })} +
+ )} + + {/* Delete confirmation modal */} + {deleteId && ( +
+
!deleting && setDeleteId(null)} /> +
+
+ +
+

Supprimer ce podcast ?

+

+ Cette action est irréversible. Le fichier audio, les commentaires et les likes seront supprimés définitivement. +

+
+ + +
+
+
+ )} +
+ ) +} diff --git a/src/pages/PodcastDetail.tsx b/src/pages/PodcastDetail.tsx index c5df1c0..6ec9362 100644 --- a/src/pages/PodcastDetail.tsx +++ b/src/pages/PodcastDetail.tsx @@ -8,6 +8,7 @@ import type { Podcast, Comment } from '@/types' import { formatDuration, timeAgo } from '@/lib/utils' import { Avatar } from '@/components/ui/Avatar' import { Badge } from '@/components/ui/Badge' +import { publicUrl } from '@/lib/storage' import { Button } from '@/components/ui/Button' export function PodcastDetail() { @@ -108,7 +109,7 @@ export function PodcastDetail() {
{podcast.cover_url ? ( - + ) : (
{podcast.title[0]?.toUpperCase()} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 6a3bddd..0d5676c 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' -import { useNavigate } from 'react-router-dom' -import { Settings as SettingsIcon, LogOut, CreditCard, Crown } from 'lucide-react' +import { useNavigate, Link } from 'react-router-dom' +import { Settings as SettingsIcon, LogOut, CreditCard, Crown, Mic, ChevronRight } from 'lucide-react' import { supabase } from '@/lib/supabase' import { useAuthStore } from '@/stores/auth' import { Avatar } from '@/components/ui/Avatar' @@ -70,6 +70,20 @@ export function Settings() {
+ +
+ +
+
+

Mes podcasts

+

Gérer, modifier ou supprimer vos publications

+
+ + +

diff --git a/src/pages/Upload.tsx b/src/pages/Upload.tsx index 8ed2717..11359d7 100644 --- a/src/pages/Upload.tsx +++ b/src/pages/Upload.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useCallback, useEffect } from 'react' 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 { useAuthStore } from '@/stores/auth' import { Button } from '@/components/ui/Button' @@ -15,6 +15,7 @@ interface OEmbedData { duration: number audioUrl: string platform: string + author: string } function extractVideoId(url: string): { platform: string; id: string } | null { @@ -36,38 +37,101 @@ function extractVideoId(url: string): { platform: string; id: string } | 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 { const info = extractVideoId(url) if (!info) return null try { 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 res = await fetch(oembedUrl) if (!res.ok) return null const data = await res.json() return { title: data.title || '', - description: '', - thumbnail: `https://img.youtube.com/vi/${info.id}/hqdefault.jpg`, + description: data.author_name ? `Par ${data.author_name}` : '', + thumbnail: `https://img.youtube.com/vi/${info.id}/maxresdefault.jpg`, duration: 0, audioUrl: url, platform: 'YouTube', + author: data.author_name || '', } } if (info.platform === 'dailymotion') { - const oembedUrl = `https://www.dailymotion.com/services/oembed?url=https://www.dailymotion.com/video/${info.id}&format=json` - const res = await fetch(oembedUrl) - if (!res.ok) return null + // Dailymotion public Data API — returns duration, description, tags, author + 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(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 owner = data.owner?.screenname || '' + return { title: data.title || '', - description: '', - thumbnail: data.thumbnail_url || `https://www.dailymotion.com/thumbnail/video/${info.id}`, - duration: 0, + description: data.description || (owner ? `Par ${owner}` : ''), + thumbnail: data.thumbnail_720_url || `https://www.dailymotion.com/thumbnail/video/${info.id}`, + duration: data.duration || 0, audioUrl: url, platform: 'Dailymotion', + author: owner, } } @@ -76,13 +140,19 @@ async function fetchVideoMeta(url: string): Promise { const res = await fetch(oembedUrl) if (!res.ok) return null 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 { title: data.title || '', - description: data.description || '', + description: data.description || (author ? `Par ${author}` : ''), thumbnail: data.thumbnail_url || '', duration: 0, audioUrl: url, platform: 'SoundCloud', + author, } } } catch { @@ -114,6 +184,7 @@ export function Upload() { // Recording mode const [isRecording, setIsRecording] = useState(false) const [recordingTime, setRecordingTime] = useState(0) + const [micPermission, setMicPermission] = useState<'granted' | 'denied' | 'prompt' | 'checking'>('checking') const mediaRecorderRef = useRef(null) const chunksRef = useRef([]) const timerRef = useRef | null>(null) @@ -122,6 +193,25 @@ export function Upload() { const [externalUrl, setExternalUrl] = useState('') const [fetching, setFetching] = useState(false) 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 useEffect(() => { @@ -135,8 +225,10 @@ export function Upload() { }, []) async function startRecording() { + setError('') try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + setMicPermission('granted') const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' }) mediaRecorderRef.current = mediaRecorder chunksRef.current = [] @@ -161,8 +253,16 @@ export function Upload() { setIsRecording(true) setRecordingTime(0) timerRef.current = setInterval(() => setRecordingTime(t => t + 1), 1000) - } catch { - setError('Impossible d\'acceder au microphone. Verifiez les permissions de votre navigateur.') + } catch (err) { + 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.thumbnail) setCoverPreview(meta.thumbnail) if (meta.duration) setDuration(meta.duration) + if (meta.author) setAuthor(meta.author) setPlatform(meta.platform) setFetching(false) } @@ -371,6 +472,7 @@ export function Upload() { setDuration(0) setError('') setPlatform('') + setAuthor('') }} className="text-sm text-text-secondary hover:text-text transition-colors cursor-pointer" > @@ -438,22 +540,42 @@ export function Upload() {

{/* Record button */} - + ) : ( + + )}
) : (
@@ -498,14 +620,23 @@ export function Upload() { {platform && coverPreview && (
- +

{title || 'Sans titre'}

-

via {platform}

+
+ {author && {author}} + {author && duration > 0 && ·} + {duration > 0 && {Math.floor(duration / 60)}:{(duration % 60).toString().padStart(2, '0')}} +
+
+ + {platform} + + + Auto-detecte + +
- - Auto-detecte -
)}
diff --git a/src/stores/player.ts b/src/stores/player.ts index a3a94cc..6760bc8 100644 --- a/src/stores/player.ts +++ b/src/stores/player.ts @@ -1,19 +1,66 @@ import { create } from 'zustand' import type { Podcast } from '@/types' import { isExternalUrl, getEmbedInfo } from '@/lib/embed' +import { publicUrl } from '@/lib/storage' -// Hidden iframe manager for external content -let hiddenIframe: HTMLIFrameElement | null = null - -function destroyIframe() { - if (hiddenIframe) { - hiddenIframe.remove() - hiddenIframe = null +// ──── YouTube IFrame API ──── +declare global { + interface Window { + YT: any + onYouTubeIframeAPIReady: () => void } } -function createHiddenIframe(embedUrl: string) { +let ytApiReady = false +let ytApiCallbacks: (() => void)[] = [] + +function loadYouTubeAPI(): Promise { + 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 | 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) { + destroyAll() const iframe = document.createElement('iframe') iframe.src = embedUrl iframe.allow = 'autoplay; encrypted-media' @@ -22,6 +69,7 @@ function createHiddenIframe(embedUrl: string) { hiddenIframe = iframe } +// ──── Store ──── interface PlayerState { current: Podcast | null isPlaying: boolean @@ -50,13 +98,19 @@ export const usePlayerStore = create((set, get) => ({ audio: null, play: (podcast) => { - const { audio, current } = get() + const { audio, current, volume } = get() const external = isExternalUrl(podcast.audio_url) + const embed = external ? getEmbedInfo(podcast.audio_url) : null // Resume same podcast if (current?.id === podcast.id) { + if (external && embed?.platform === 'youtube' && ytPlayer) { + try { ytPlayer.playVideo() } catch { /* ignore */ } + startYtProgressTracking() + set({ isPlaying: true }) + return + } if (external) { - const embed = getEmbedInfo(podcast.audio_url) if (embed) createHiddenIframe(embed.embedUrl) set({ isPlaying: true }) return @@ -69,22 +123,72 @@ export const usePlayerStore = create((set, get) => ({ } // Stop previous - if (audio) { - audio.pause() - audio.removeAttribute('src') - } - destroyIframe() + if (audio) { audio.pause(); audio.removeAttribute('src') } + destroyAll() - // External: play via hidden iframe - if (external) { - const embed = getEmbedInfo(podcast.audio_url) - if (embed) createHiddenIframe(embed.embedUrl) + // ── 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 }) + + 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 }) return } - const newAudio = new Audio(podcast.audio_url) - newAudio.volume = get().volume + // ── Native audio ── + const newAudio = new Audio(publicUrl(podcast.audio_url)) + newAudio.volume = volume newAudio.addEventListener('timeupdate', () => { set({ progress: newAudio.currentTime }) @@ -102,16 +206,35 @@ export const usePlayerStore = create((set, get) => ({ toggle: () => { const { audio, isPlaying, isExternal, current } = get() + 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) { destroyIframe() - } else { - const embed = current ? getEmbedInfo(current.audio_url) : null - if (embed) createHiddenIframe(embed.embedUrl) + } else if (embed) { + createHiddenIframe(embed.embedUrl) } set({ isPlaying: !isPlaying }) return } + if (!audio) return if (isPlaying) { audio.pause() @@ -122,14 +245,35 @@ export const usePlayerStore = create((set, get) => ({ }, pause: () => { - const { isExternal } = get() - if (isExternal) destroyIframe() + const { isExternal, current } = get() + 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() set({ isPlaying: false }) }, 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 audio.currentTime = time set({ progress: time }) @@ -138,9 +282,25 @@ export const usePlayerStore = create((set, get) => ({ setVolume: (vol) => { const { audio } = get() if (audio) audio.volume = vol + if (ytPlayer) try { ytPlayer.setVolume(vol * 100) } catch { /* ignore */ } set({ volume: vol }) }, setProgress: (progress) => set({ progress }), 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) +} diff --git a/supabase/migrations/002_storage_buckets.sql b/supabase/migrations/002_storage_buckets.sql new file mode 100644 index 0000000..3d22a3f --- /dev/null +++ b/supabase/migrations/002_storage_buckets.sql @@ -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]);