+
+
+
+
+ {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) => (
+
+ ))}
+
+ )}
+
{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: {