diff --git a/src/App.tsx b/src/App.tsx index 8d623f1..11a6000 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,8 @@ -import { useEffect } from 'react' -import { Routes, Route } from 'react-router-dom' +import { useEffect, useRef } from 'react' +import { Routes, Route, useNavigate, useLocation } from 'react-router-dom' import { supabase } from '@/lib/supabase' import { useAuthStore } from '@/stores/auth' +import { useOnlineStatus } from '@/hooks/useOnlineStatus' import { Layout } from '@/components/layout/Layout' import { Home } from '@/pages/Home' import { Explore } from '@/pages/Explore' @@ -12,9 +13,14 @@ import { Profile } from '@/pages/Profile' import { Favorites } from '@/pages/Favorites' import { Settings } from '@/pages/Settings' import { MyPodcasts } from '@/pages/MyPodcasts' +import { Offline } from '@/pages/Offline' export default function App() { const { setUser, fetchProfile } = useAuthStore() + const isOnline = useOnlineStatus() + const navigate = useNavigate() + const location = useLocation() + const prevPathRef = useRef(location.pathname) useEffect(() => { supabase.auth.getSession().then(({ data: { session } }) => { @@ -30,6 +36,16 @@ export default function App() { return () => subscription.unsubscribe() }, [setUser, fetchProfile]) + // Redirect to /offline when connection drops, restore when back online + useEffect(() => { + if (!isOnline && location.pathname !== '/offline') { + prevPathRef.current = location.pathname + navigate('/offline', { replace: true }) + } else if (isOnline && location.pathname === '/offline') { + navigate(prevPathRef.current || '/', { replace: true }) + } + }, [isOnline, location.pathname, navigate]) + return ( }> @@ -42,6 +58,7 @@ export default function App() { } /> } /> } /> + } /> ) diff --git a/src/components/layout/PlayerBar.tsx b/src/components/layout/PlayerBar.tsx index 628712f..2b50529 100644 --- a/src/components/layout/PlayerBar.tsx +++ b/src/components/layout/PlayerBar.tsx @@ -1,5 +1,5 @@ import { useRef, useState, useCallback } from 'react' -import { Play, Pause, Volume2, VolumeX, SkipBack, SkipForward, Maximize2, Minimize2 } from 'lucide-react' +import { Play, Pause, Volume2, VolumeX, SkipBack, SkipForward, Maximize2, Minimize2, ListMusic, X } from 'lucide-react' import { usePlayerStore } from '@/stores/player' import { formatDuration } from '@/lib/utils' import { publicUrl } from '@/lib/storage' @@ -7,11 +7,12 @@ import { Avatar } from '@/components/ui/Avatar' import { isExternalUrl, getEmbedInfo } from '@/lib/embed' export function PlayerBar() { - const { current, isPlaying, isExternal, progress, duration, volume, toggle, seek, setVolume } = usePlayerStore() + const { current, isPlaying, isExternal, progress, duration, volume, toggle, seek, setVolume, playbackRate, cyclePlaybackRate, queue, removeFromQueue, clearQueue, playNext, play } = usePlayerStore() const seekRef = useRef(null) const [isDragging, setIsDragging] = useState(false) const [dragPct, setDragPct] = useState(0) const [ytExpanded, setYtExpanded] = useState(false) + const [queueOpen, setQueueOpen] = useState(false) const calcPct = useCallback((clientX: number) => { if (!seekRef.current) return 0 @@ -24,10 +25,17 @@ export function PlayerBar() { const embedInfo = isExternal && current ? getEmbedInfo(current.audio_url) : null const isYouTube = embedInfo?.platform === 'youtube' const isSpotify = embedInfo?.platform === 'spotify' - const pct = duration > 0 ? (progress / duration) * 100 : 0 + const chapters = current?.chapters || [] + // Use store duration, fallback to podcast metadata duration + const effectiveDuration = duration > 0 ? duration : (current?.duration_seconds || 0) + const currentChapter = chapters.length > 0 && effectiveDuration > 0 + ? [...chapters].reverse().find((ch) => progress >= ch.start_time_seconds) + : null + + const pct = effectiveDuration > 0 ? (progress / effectiveDuration) * 100 : 0 const displayPct = isDragging ? dragPct : pct // Allow seeking for native audio and YouTube (via IFrame API) - const canSeek = duration > 0 + const canSeek = effectiveDuration > 0 const showBar = true function startDrag(clientX: number) { @@ -45,7 +53,7 @@ export function PlayerBar() { const onUp = (ev: MouseEvent) => { setIsDragging(false) const p = calcPct(ev.clientX) - seek((p / 100) * duration) + seek((p / 100) * effectiveDuration) window.removeEventListener('mousemove', onMove) window.removeEventListener('mouseup', onUp) } @@ -61,7 +69,7 @@ export function PlayerBar() { 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) + seek((calcPct(ev.changedTouches[0].clientX) / 100) * effectiveDuration) el.removeEventListener('touchmove', onMove) el.removeEventListener('touchend', onEnd) } @@ -71,10 +79,10 @@ export function PlayerBar() { function handleClick(e: React.MouseEvent) { if (!canSeek || isDragging) return - seek((calcPct(e.clientX) / 100) * duration) + seek((calcPct(e.clientX) / 100) * effectiveDuration) } - const currentTime = isDragging ? (dragPct / 100) * duration : progress + const currentTime = isDragging ? (dragPct / 100) * effectiveDuration : progress return ( <> @@ -132,7 +140,9 @@ export function PlayerBar() {

{current.title}

-

{current.creator?.username}

+

+ {currentChapter ? currentChapter.title : current.creator?.username} +

@@ -155,20 +165,41 @@ export function PlayerBar() { {canSeek && ( )} + {/* Speed button — only for native audio + YouTube */} + {canSeek && !isSpotify && ( + + )} - {/* Time + Volume (desktop) */} + {/* Time + Queue + Volume (desktop) */}
- {duration > 0 - ? `${formatDuration(currentTime)} / ${formatDuration(duration)}` + {effectiveDuration > 0 + ? `${formatDuration(currentTime)} / ${formatDuration(effectiveDuration)}` : ''} +
} + {/* Queue panel */} + {queueOpen && ( +
+
+ File d'attente ({queue.length}) +
+ {queue.length > 0 && ( + + )} + +
+
+
+ {queue.length === 0 ? ( +

La file est vide

+ ) : ( + queue.map((p, i) => ( +
+ {i + 1} + {p.cover_url ? ( + + ) : ( +
+ {p.title[0]?.toUpperCase()} +
+ )} +
+

{p.title}

+

{p.external_author || p.creator?.username}

+
+ +
+ )) + )} +
+
+ )} ) } diff --git a/src/components/podcast/PodcastCard.tsx b/src/components/podcast/PodcastCard.tsx index fa7be48..87a9306 100644 --- a/src/components/podcast/PodcastCard.tsx +++ b/src/components/podcast/PodcastCard.tsx @@ -1,4 +1,4 @@ -import { Play, Pause, Heart, MessageCircle, Clock } from 'lucide-react' +import { Play, Pause, Heart, MessageCircle, Clock, ListPlus, Download, Check, Loader2 } from 'lucide-react' import { Link } from 'react-router-dom' import type { Podcast } from '@/types' import { formatDuration, timeAgo } from '@/lib/utils' @@ -7,6 +7,8 @@ import { Badge } from '@/components/ui/Badge' import { Avatar } from '@/components/ui/Avatar' import { usePlayerStore } from '@/stores/player' import { useListeningProgress } from '@/hooks/useListeningProgress' +import { useOfflineStore } from '@/stores/offline' +import { isExternalUrl } from '@/lib/embed' const coverGradients = [ 'from-primary/20 via-primary/5 to-accent/10', @@ -28,11 +30,16 @@ interface PodcastCardProps { export function PodcastCard({ podcast, progressPercent: propProgress }: PodcastCardProps) { const play = usePlayerStore((s) => s.play) + const addToQueue = usePlayerStore((s) => s.addToQueue) const currentId = usePlayerStore((s) => s.current?.id) const isPlaying = usePlayerStore((s) => s.isPlaying) const isActive = currentId === podcast.id const progressMap = useListeningProgress() const progressPercent = propProgress ?? progressMap.get(podcast.id)?.percent + const { downloadPodcast, isDownloaded, isDownloading, getDownloadProgress } = useOfflineStore() + const downloaded = isDownloaded(podcast.id) + const downloading = isDownloading(podcast.id) + const isNative = !isExternalUrl(podcast.audio_url) return (
@@ -56,17 +63,41 @@ export function PodcastCard({ podcast, progressPercent: propProgress }: PodcastC )} {/* Play overlay */} - +
+ + {isNative && !downloaded && ( + + )} + {isNative && downloaded && ( +
+ +
+ )}
- + {/* Duration pill */}
diff --git a/src/hooks/useOnlineStatus.ts b/src/hooks/useOnlineStatus.ts new file mode 100644 index 0000000..e8d9883 --- /dev/null +++ b/src/hooks/useOnlineStatus.ts @@ -0,0 +1,18 @@ +import { useSyncExternalStore } from 'react' + +function subscribe(callback: () => void) { + window.addEventListener('online', callback) + window.addEventListener('offline', callback) + return () => { + window.removeEventListener('online', callback) + window.removeEventListener('offline', callback) + } +} + +function getSnapshot() { + return navigator.onLine +} + +export function useOnlineStatus() { + return useSyncExternalStore(subscribe, getSnapshot) +} diff --git a/src/lib/offline.ts b/src/lib/offline.ts new file mode 100644 index 0000000..4578123 --- /dev/null +++ b/src/lib/offline.ts @@ -0,0 +1,93 @@ +const CACHE_NAME = 'wetalk-audio-cache' + +export async function cacheAudio( + url: string, + onProgress?: (percent: number) => void, +): Promise { + try { + const cache = await caches.open(CACHE_NAME) + + // Check if already cached + const existing = await cache.match(url) + if (existing) return true + + const response = await fetch(url) + if (!response.ok) return false + + if (onProgress && response.body) { + const contentLength = Number(response.headers.get('content-length') || 0) + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let received = 0 + + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + received += value.length + if (contentLength > 0) onProgress(Math.round((received / contentLength) * 100)) + } + + const blob = new Blob(chunks, { type: response.headers.get('content-type') || 'audio/mpeg' }) + const cachedResponse = new Response(blob, { + status: 200, + headers: { 'Content-Type': blob.type, 'Content-Length': String(blob.size) }, + }) + await cache.put(url, cachedResponse) + } else { + await cache.put(url, response) + } + + return true + } catch (e) { + console.error('[offline] cache failed:', e) + return false + } +} + +export async function getCachedAudioUrl(url: string): Promise { + try { + const cache = await caches.open(CACHE_NAME) + const response = await cache.match(url) + if (!response) return null + const blob = await response.blob() + return URL.createObjectURL(blob) + } catch { + return null + } +} + +export async function isAudioCached(url: string): Promise { + try { + const cache = await caches.open(CACHE_NAME) + const response = await cache.match(url) + return !!response + } catch { + return false + } +} + +export async function removeCachedAudio(url: string): Promise { + try { + const cache = await caches.open(CACHE_NAME) + await cache.delete(url) + } catch { /* ignore */ } +} + +export async function getCacheSize(): Promise { + try { + const cache = await caches.open(CACHE_NAME) + const keys = await cache.keys() + let total = 0 + for (const req of keys) { + const res = await cache.match(req) + if (res) { + const blob = await res.blob() + total += blob.size + } + } + return total + } catch { + return 0 + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 52eb8dd..ab6a5b3 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,4 +1,5 @@ export function formatDuration(seconds: number): string { + if (!seconds || !isFinite(seconds) || isNaN(seconds)) return '0:00' const h = Math.floor(seconds / 3600) const m = Math.floor((seconds % 3600) / 60) const s = Math.floor(seconds % 60) diff --git a/src/pages/Offline.tsx b/src/pages/Offline.tsx new file mode 100644 index 0000000..e90f715 --- /dev/null +++ b/src/pages/Offline.tsx @@ -0,0 +1,142 @@ +import { useMemo } from 'react' +import { WifiOff, Play, Pause, Trash2 } from 'lucide-react' +import { usePlayerStore } from '@/stores/player' +import { useOfflineStore } from '@/stores/offline' +import { useOnlineStatus } from '@/hooks/useOnlineStatus' +import { publicUrl } from '@/lib/storage' +import { formatDuration } from '@/lib/utils' +import { Avatar } from '@/components/ui/Avatar' +import { Button } from '@/components/ui/Button' +import type { Podcast } from '@/types' + +function getOfflinePodcasts(): Podcast[] { + try { + const meta = JSON.parse(localStorage.getItem('wetalk-offline-meta') || '{}') + return Object.values(meta) + } catch { + return [] + } +} + +export function Offline() { + const isOnline = useOnlineStatus() + const { play, toggle, current, isPlaying } = usePlayerStore() + const { downloaded, removePodcast } = useOfflineStore() + + const podcasts = useMemo(() => { + const all = getOfflinePodcasts() + return all.filter((p) => downloaded.has(p.id)) + }, [downloaded]) + + return ( +
+ {/* Header */} +
+
+ +
+
+

Mode hors-ligne

+

+ {isOnline + ? 'Vos épisodes téléchargés, prêts à écouter sans connexion.' + : 'Pas de connexion — voici vos épisodes disponibles.'} +

+
+
+ + {!isOnline && ( +
+ + Vous êtes hors-ligne. Seuls les épisodes téléchargés sont disponibles. +
+ )} + + {podcasts.length === 0 ? ( +
+
+ +
+

Aucun épisode téléchargé

+

+ {isOnline + ? 'Téléchargez des épisodes depuis la page d\'un podcast pour les écouter hors-ligne.' + : 'Reconnectez-vous pour télécharger des épisodes.'} +

+
+ ) : ( +
+ {podcasts.map((podcast) => { + const isActive = current?.id === podcast.id + return ( +
+ {/* Cover */} +
+ {podcast.cover_url ? ( + + ) : ( + + )} + {isActive && isPlaying && ( +
+
+
+
+
+
+
+ )} +
+ + {/* Info */} +
+

{podcast.title}

+

+ {podcast.external_author || (podcast.creator as any)?.username || 'Inconnu'} +

+ {podcast.duration_seconds > 0 && ( +

+ {formatDuration(podcast.duration_seconds)} +

+ )} +
+ + {/* Actions */} +
+ + +
+
+ ) + })} + +

+ {podcasts.length} épisode{podcasts.length > 1 ? 's' : ''} téléchargé{podcasts.length > 1 ? 's' : ''} +

+
+ )} +
+ ) +} diff --git a/src/pages/PodcastDetail.tsx b/src/pages/PodcastDetail.tsx index 3fc5544..fe13bc5 100644 --- a/src/pages/PodcastDetail.tsx +++ b/src/pages/PodcastDetail.tsx @@ -1,22 +1,25 @@ import { useEffect, useState } from 'react' import { useParams, Link } from 'react-router-dom' -import { Play, Pause, Heart, MessageCircle, Clock, Share2 } from 'lucide-react' +import { Play, Pause, Heart, MessageCircle, Clock, Share2, ListPlus, Download, Check, Loader2 } from 'lucide-react' import { supabase } from '@/lib/supabase' import { useAuthStore } from '@/stores/auth' import { usePlayerStore } from '@/stores/player' -import type { Podcast, Comment } from '@/types' +import type { Podcast, Comment, Chapter } 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' import { useListeningProgress } from '@/hooks/useListeningProgress' +import { useOfflineStore } from '@/stores/offline' +import { isExternalUrl } from '@/lib/embed' export function PodcastDetail() { const { id } = useParams<{ id: string }>() const { user } = useAuthStore() - const { play, toggle, current, isPlaying } = usePlayerStore() + const { play, toggle, current, isPlaying, addToQueue, seek: playerSeek } = usePlayerStore() const [podcast, setPodcast] = useState(null) + const [chapters, setChapters] = useState([]) const [comments, setComments] = useState([]) const [newComment, setNewComment] = useState('') const [isLiked, setIsLiked] = useState(false) @@ -26,6 +29,7 @@ export function PodcastDetail() { const isActive = current?.id === id const progressMap = useListeningProgress() const progressInfo = id ? progressMap.get(id) : undefined + const { downloadPodcast, removePodcast, isDownloaded, isDownloading, getDownloadProgress } = useOfflineStore() useEffect(() => { if (!id) return @@ -50,6 +54,13 @@ export function PodcastDetail() { if (commentsData) setComments(commentsData) + const { data: chaptersData } = await supabase + .from('chapters') + .select('*') + .eq('podcast_id', id) + .order('sort_order', { ascending: true }) + if (chaptersData) setChapters(chaptersData) + if (user) { const { data: like } = await supabase .from('likes') @@ -176,6 +187,25 @@ export function PodcastDetail() { {likesCount} + + {podcast && !isExternalUrl(podcast.audio_url) && ( + isDownloaded(podcast.id) ? ( + + ) : isDownloading(podcast.id) ? ( + + ) : ( + + ) + )} @@ -190,6 +220,32 @@ export function PodcastDetail() {
)} + {chapters.length > 0 && ( +
+

+ + Chapitres ({chapters.length}) +

+
+ {chapters.map((ch) => ( + + ))} +
+
+ )} +

diff --git a/src/pages/Upload.tsx b/src/pages/Upload.tsx index 268eb07..d1615a4 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, Circle, Square, MicOff, ShieldAlert } from 'lucide-react' +import { Upload as UploadIcon, Music, X, Image, Mic, Link2, Loader2, Circle, Square, MicOff, ShieldAlert, Plus, Trash2 } from 'lucide-react' import { supabase } from '@/lib/supabase' import { useAuthStore } from '@/stores/auth' import { Button } from '@/components/ui/Button' @@ -57,6 +57,10 @@ export function Upload() { const mediaRecorderRef = useRef(null) const chunksRef = useRef([]) const timerRef = useRef | null>(null) + const recordingTimeRef = useRef(0) + + // Chapters + const [chapters, setChapters] = useState<{ title: string; minutes: string; seconds: string }[]>([]) // External mode const [externalUrl, setExternalUrl] = useState('') @@ -114,7 +118,7 @@ export function Upload() { const blob = new Blob(chunksRef.current, { type: 'audio/webm' }) const file = new File([blob], `enregistrement-${Date.now()}.webm`, { type: 'audio/webm' }) setAudioFile(file) - setDuration(recordingTime) + setDuration(recordingTimeRef.current) if (!title) setTitle('Mon enregistrement') setIsRecording(false) } @@ -122,7 +126,11 @@ export function Upload() { mediaRecorder.start(1000) setIsRecording(true) setRecordingTime(0) - timerRef.current = setInterval(() => setRecordingTime(t => t + 1), 1000) + recordingTimeRef.current = 0 + timerRef.current = setInterval(() => { + recordingTimeRef.current += 1 + setRecordingTime(recordingTimeRef.current) + }, 1000) } catch (err) { const e = err as DOMException if (e.name === 'NotAllowedError') { @@ -255,10 +263,13 @@ export function Upload() { } } + // External content → wetalk system account, original → logged-in user + const WETALK_SYSTEM_ID = 'a1000000-0000-0000-0000-000000000005' + const { data: podcast, error: insertErr } = await supabase .from('podcasts') .insert({ - creator_id: user.id, + creator_id: mode === 'external' ? WETALK_SYSTEM_ID : user.id, title, description, audio_url, @@ -275,6 +286,21 @@ export function Upload() { return } + // Save chapters if any + if (chapters.length > 0 && podcast) { + const validChapters = chapters + .filter((ch) => ch.title.trim()) + .map((ch, i) => ({ + podcast_id: podcast.id, + title: ch.title.trim(), + start_time_seconds: parseInt(ch.minutes || '0') * 60 + parseInt(ch.seconds || '0'), + sort_order: i, + })) + if (validChapters.length > 0) { + await supabase.from('chapters').insert(validChapters) + } + } + navigate(`/podcast/${podcast.id}`) } @@ -571,6 +597,72 @@ export function Upload() {

)} + {/* Chapter editor — original mode only */} + {mode === 'original' && audioFile && ( +
+
+ + +
+ {chapters.map((ch, i) => ( +
+
+ { + const next = [...chapters] + next[i] = { ...ch, minutes: e.target.value } + setChapters(next) + }} + className="w-12 rounded-lg border border-border bg-surface px-2 py-1.5 text-sm text-center outline-none focus:border-primary" + placeholder="0" + /> + : + { + const next = [...chapters] + next[i] = { ...ch, seconds: e.target.value.padStart(2, '0').slice(-2) } + setChapters(next) + }} + className="w-12 rounded-lg border border-border bg-surface px-2 py-1.5 text-sm text-center outline-none focus:border-primary" + placeholder="00" + /> +
+ { + const next = [...chapters] + next[i] = { ...ch, title: e.target.value } + setChapters(next) + }} + className="flex-1 rounded-lg border border-border bg-surface px-3 py-1.5 text-sm outline-none focus:border-primary" + placeholder="Titre du chapitre" + /> + +
+ ))} +
+ )} + {error &&

{error}

} {(mode === 'original' || platform) && ( diff --git a/src/stores/offline.ts b/src/stores/offline.ts new file mode 100644 index 0000000..e75a1e8 --- /dev/null +++ b/src/stores/offline.ts @@ -0,0 +1,112 @@ +import { create } from 'zustand' +import { cacheAudio, removeCachedAudio, isAudioCached } from '@/lib/offline' +import { publicUrl } from '@/lib/storage' +import { isExternalUrl } from '@/lib/embed' +import type { Podcast } from '@/types' + +interface DownloadInfo { + podcastId: string + progress: number // 0-100 +} + +interface OfflineState { + downloaded: Set + downloading: Map + downloadPodcast: (podcast: Podcast) => Promise + removePodcast: (podcast: Podcast) => Promise + isDownloaded: (podcastId: string) => boolean + isDownloading: (podcastId: string) => boolean + getDownloadProgress: (podcastId: string) => number + initFromStorage: () => void +} + +function loadDownloaded(): Set { + try { + const saved = localStorage.getItem('wetalk-offline-podcasts') + return saved ? new Set(JSON.parse(saved)) : new Set() + } catch { return new Set() } +} + +function saveDownloaded(ids: Set) { + localStorage.setItem('wetalk-offline-podcasts', JSON.stringify([...ids])) +} + +// Store podcast metadata for offline access +function savePodcastMeta(podcast: Podcast) { + try { + const stored = JSON.parse(localStorage.getItem('wetalk-offline-meta') || '{}') + stored[podcast.id] = { + id: podcast.id, + title: podcast.title, + audio_url: podcast.audio_url, + cover_url: podcast.cover_url, + duration_seconds: podcast.duration_seconds, + creator: podcast.creator, + external_author: podcast.external_author, + } + localStorage.setItem('wetalk-offline-meta', JSON.stringify(stored)) + } catch { /* ignore */ } +} + +function removePodcastMeta(podcastId: string) { + try { + const stored = JSON.parse(localStorage.getItem('wetalk-offline-meta') || '{}') + delete stored[podcastId] + localStorage.setItem('wetalk-offline-meta', JSON.stringify(stored)) + } catch { /* ignore */ } +} + +export const useOfflineStore = create((set, get) => ({ + downloaded: loadDownloaded(), + downloading: new Map(), + + downloadPodcast: async (podcast) => { + if (isExternalUrl(podcast.audio_url)) return + if (get().downloaded.has(podcast.id)) return + if (get().downloading.has(podcast.id)) return + + const url = publicUrl(podcast.audio_url) + + set((s) => ({ downloading: new Map(s.downloading).set(podcast.id, 0) })) + + const success = await cacheAudio(url, (pct) => { + set((s) => ({ downloading: new Map(s.downloading).set(podcast.id, pct) })) + }) + + if (success) { + savePodcastMeta(podcast) + const next = new Set(get().downloaded) + next.add(podcast.id) + saveDownloaded(next) + set((s) => { + const dl = new Map(s.downloading) + dl.delete(podcast.id) + return { downloaded: next, downloading: dl } + }) + } else { + set((s) => { + const dl = new Map(s.downloading) + dl.delete(podcast.id) + return { downloading: dl } + }) + } + }, + + removePodcast: async (podcast) => { + const url = publicUrl(podcast.audio_url) + await removeCachedAudio(url) + removePodcastMeta(podcast.id) + const next = new Set(get().downloaded) + next.delete(podcast.id) + saveDownloaded(next) + set({ downloaded: next }) + }, + + isDownloaded: (podcastId) => get().downloaded.has(podcastId), + isDownloading: (podcastId) => get().downloading.has(podcastId), + getDownloadProgress: (podcastId) => get().downloading.get(podcastId) ?? 0, + + initFromStorage: () => { + set({ downloaded: loadDownloaded() }) + }, +})) diff --git a/src/stores/player.ts b/src/stores/player.ts index 0eb65d7..0fd3d85 100644 --- a/src/stores/player.ts +++ b/src/stores/player.ts @@ -5,6 +5,7 @@ import { publicUrl } from '@/lib/storage' import { supabase } from '@/lib/supabase' import { useAuthStore } from '@/stores/auth' import { invalidateProgressCache } from '@/hooks/useListeningProgress' +import { getCachedAudioUrl } from '@/lib/offline' // ──── YouTube IFrame API ──── declare global { @@ -80,14 +81,39 @@ function createHiddenIframe(embedUrl: string) { hiddenIframe = iframe } +// ──── Offline progress persistence (localStorage fallback) ──── +const OFFLINE_PROGRESS_KEY = 'wetalk-offline-progress' + +function saveProgressLocal(podcastId: string, seconds: number, duration: number, completed = false) { + try { + const stored = JSON.parse(localStorage.getItem(OFFLINE_PROGRESS_KEY) || '{}') + stored[podcastId] = { seconds: Math.floor(seconds), duration, completed, at: Date.now() } + localStorage.setItem(OFFLINE_PROGRESS_KEY, JSON.stringify(stored)) + } catch { /* ignore */ } +} + +function fetchProgressLocal(podcastId: string): number { + try { + const stored = JSON.parse(localStorage.getItem(OFFLINE_PROGRESS_KEY) || '{}') + const entry = stored[podcastId] + return entry?.seconds > 5 ? entry.seconds : 0 + } catch { return 0 } +} + // ──── Progress persistence ──── let saveTimeout: ReturnType | null = null async function saveProgressNow(podcastId: string, seconds: number, duration: number, markCompleted = false): Promise { if (saveTimeout) { clearTimeout(saveTimeout); saveTimeout = null } - const userId = useAuthStore.getState().user?.id - if (!userId || seconds < 1) return + if (seconds < 1) return + + // Always save to localStorage (works offline) const completed = markCompleted || (duration > 0 && seconds / duration > 0.95) + saveProgressLocal(podcastId, seconds, duration, completed) + + // Try Supabase if online + authenticated + const userId = useAuthStore.getState().user?.id + if (!userId || !navigator.onLine) return 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' } @@ -105,20 +131,35 @@ function debouncedSaveProgress(podcastId: string, seconds: number, duration: num } async function fetchSavedProgress(podcastId: string): Promise { + // Try Supabase first if online 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 + if (userId && navigator.onLine) { + 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 + if (saved > 5) return saved + } + // Fallback to localStorage + return fetchProgressLocal(podcastId) } // ──── Store ──── +const PLAYBACK_RATES = [0.5, 0.75, 1, 1.25, 1.5, 2] as const +type PlaybackRate = (typeof PLAYBACK_RATES)[number] + +function getSavedPlaybackRate(): PlaybackRate { + const saved = localStorage.getItem('wetalk-playback-rate') + if (saved) { + const n = parseFloat(saved) + if (PLAYBACK_RATES.includes(n as PlaybackRate)) return n as PlaybackRate + } + return 1 +} + interface PlayerState { current: Podcast | null isPlaying: boolean @@ -127,6 +168,8 @@ interface PlayerState { duration: number volume: number audio: HTMLAudioElement | null + playbackRate: PlaybackRate + queue: Podcast[] play: (podcast: Podcast) => void toggle: () => void @@ -135,6 +178,23 @@ interface PlayerState { setVolume: (vol: number) => void setProgress: (progress: number) => void setDuration: (duration: number) => void + cyclePlaybackRate: () => void + setPlaybackRate: (rate: PlaybackRate) => void + addToQueue: (podcast: Podcast) => void + removeFromQueue: (podcastId: string) => void + clearQueue: () => void + playNext: () => void +} + +function getSavedQueue(): Podcast[] { + try { + const saved = localStorage.getItem('wetalk-queue') + return saved ? JSON.parse(saved) : [] + } catch { return [] } +} + +function persistQueue(queue: Podcast[]) { + localStorage.setItem('wetalk-queue', JSON.stringify(queue)) } export const usePlayerStore = create((set, get) => ({ @@ -145,6 +205,8 @@ export const usePlayerStore = create((set, get) => ({ duration: 0, volume: 0.8, audio: null, + playbackRate: getSavedPlaybackRate(), + queue: getSavedQueue(), play: (podcast) => { const { audio, current, volume } = get() @@ -213,6 +275,7 @@ export const usePlayerStore = create((set, get) => ({ events: { onReady: (e: any) => { e.target.setVolume(volume * 100) + e.target.setPlaybackRate(get().playbackRate) e.target.playVideo() const dur = e.target.getDuration() if (dur > 0) set({ duration: dur }) @@ -230,6 +293,8 @@ export const usePlayerStore = create((set, get) => ({ const podId = usePlayerStore.getState().current?.id if (podId) saveProgressNow(podId, e.target.getDuration(), e.target.getDuration(), true) set({ isPlaying: false, progress: 0 }) + // Auto-play next from queue + setTimeout(() => usePlayerStore.getState().playNext(), 100) } else if (e.data === 1) { const dur = e.target.getDuration() if (dur > 0) set({ duration: dur }) @@ -264,30 +329,45 @@ export const usePlayerStore = create((set, get) => ({ } // ── Native audio ── - const newAudio = new Audio(publicUrl(podcast.audio_url)) - newAudio.volume = volume + const audioSrc = publicUrl(podcast.audio_url) - newAudio.addEventListener('timeupdate', () => { - set({ progress: newAudio.currentTime }) - debouncedSaveProgress(podcast.id, newAudio.currentTime, newAudio.duration || 0) - }) - newAudio.addEventListener('loadedmetadata', () => { - 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 }) - } + // Try to use cached version first + const setupAudio = (src: string) => { + const newAudio = new Audio(src) + newAudio.volume = volume + newAudio.playbackRate = get().playbackRate + + newAudio.addEventListener('timeupdate', () => { + const currentDur = isFinite(newAudio.duration) ? newAudio.duration : (get().duration || 0) + set({ progress: newAudio.currentTime }) + debouncedSaveProgress(podcast.id, newAudio.currentTime, currentDur) + }) + newAudio.addEventListener('loadedmetadata', () => { + const dur = isFinite(newAudio.duration) ? newAudio.duration : podcast.duration_seconds || 0 + set({ duration: dur }) + // 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', () => { + saveProgressNow(podcast.id, newAudio.duration, newAudio.duration, true) + set({ isPlaying: false, progress: 0 }) + // Auto-play next from queue + setTimeout(() => get().playNext(), 100) }) - }) - newAudio.addEventListener('ended', () => { - saveProgressNow(podcast.id, newAudio.duration, newAudio.duration, true) - set({ isPlaying: false, progress: 0 }) - }) - newAudio.play() - set({ audio: newAudio, current: podcast, isPlaying: true, isExternal: false, progress: 0 }) + newAudio.play() + set({ audio: newAudio, current: podcast, isPlaying: true, isExternal: false, progress: 0 }) + } + + // Check cache, fall back to network + getCachedAudioUrl(audioSrc).then((cachedUrl) => { + setupAudio(cachedUrl || audioSrc) + }) }, toggle: () => { @@ -374,6 +454,49 @@ export const usePlayerStore = create((set, get) => ({ setProgress: (progress) => set({ progress }), setDuration: (duration) => set({ duration }), + + cyclePlaybackRate: () => { + const { playbackRate } = get() + const idx = PLAYBACK_RATES.indexOf(playbackRate) + const next = PLAYBACK_RATES[(idx + 1) % PLAYBACK_RATES.length] + get().setPlaybackRate(next) + }, + + setPlaybackRate: (rate) => { + const { audio } = get() + if (audio) audio.playbackRate = rate + if (ytPlayer) try { ytPlayer.setPlaybackRate(rate) } catch { /* ignore */ } + localStorage.setItem('wetalk-playback-rate', String(rate)) + set({ playbackRate: rate }) + }, + + addToQueue: (podcast) => { + const { queue } = get() + if (queue.some((p) => p.id === podcast.id)) return + const next = [...queue, podcast] + persistQueue(next) + set({ queue: next }) + }, + + removeFromQueue: (podcastId) => { + const next = get().queue.filter((p) => p.id !== podcastId) + persistQueue(next) + set({ queue: next }) + }, + + clearQueue: () => { + persistQueue([]) + set({ queue: [] }) + }, + + playNext: () => { + const { queue, play } = get() + if (queue.length === 0) return + const [next, ...rest] = queue + persistQueue(rest) + set({ queue: rest }) + play(next) + }, })) // Track YouTube progress via polling diff --git a/src/types/index.ts b/src/types/index.ts index eacbd9f..d4001d2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -26,6 +26,7 @@ export interface Podcast { likes_count?: number comments_count?: number is_liked?: boolean + chapters?: Chapter[] } export interface Tag { @@ -34,6 +35,14 @@ export interface Tag { slug: string } +export interface Chapter { + id: string + podcast_id: string + title: string + start_time_seconds: number + sort_order: number +} + export interface Comment { id: string user_id: string diff --git a/supabase/migrations/005_chapters.sql b/supabase/migrations/005_chapters.sql new file mode 100644 index 0000000..8b09c3b --- /dev/null +++ b/supabase/migrations/005_chapters.sql @@ -0,0 +1,33 @@ +-- Chapters: timestamped sections within a podcast episode +CREATE TABLE public.chapters ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + podcast_id uuid REFERENCES public.podcasts(id) ON DELETE CASCADE NOT NULL, + title text NOT NULL, + start_time_seconds integer NOT NULL DEFAULT 0, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz DEFAULT now() +); + +CREATE INDEX idx_chapters_podcast ON public.chapters(podcast_id); + +ALTER TABLE public.chapters ENABLE ROW LEVEL SECURITY; + +-- Everyone can read chapters +CREATE POLICY "chapters_read" ON public.chapters + FOR SELECT USING (true); + +-- Only the podcast creator can manage chapters +CREATE POLICY "chapters_insert" ON public.chapters + FOR INSERT WITH CHECK ( + podcast_id IN (SELECT id FROM public.podcasts WHERE creator_id = auth.uid()) + ); + +CREATE POLICY "chapters_update" ON public.chapters + FOR UPDATE USING ( + podcast_id IN (SELECT id FROM public.podcasts WHERE creator_id = auth.uid()) + ); + +CREATE POLICY "chapters_delete" ON public.chapters + FOR DELETE USING ( + podcast_id IN (SELECT id FROM public.podcasts WHERE creator_id = auth.uid()) + ); diff --git a/vite.config.ts b/vite.config.ts index dbea9ce..f78b07e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,6 +12,17 @@ export default defineConfig({ workbox: { skipWaiting: true, clientsClaim: true, + runtimeCaching: [ + { + urlPattern: /\/storage\/v1\/object\/public\//, + handler: 'CacheFirst', + options: { + cacheName: 'wetalk-supabase-assets', + expiration: { maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }, + cacheableResponse: { statuses: [0, 200] }, + }, + }, + ], }, includeAssets: ['favicon.svg', 'icons/*.png'], manifest: {