add progression on podcasts
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 20s

This commit is contained in:
ordinarthur 2026-04-13 10:55:08 +02:00
parent 143e453f78
commit a7c4aa5608
4 changed files with 80 additions and 4 deletions

View File

@ -6,6 +6,7 @@ import { publicUrl } from '@/lib/storage'
import { Badge } from '@/components/ui/Badge'
import { Avatar } from '@/components/ui/Avatar'
import { usePlayerStore } from '@/stores/player'
import { useListeningProgress } from '@/hooks/useListeningProgress'
const coverGradients = [
'from-primary/20 via-primary/5 to-accent/10',
@ -25,11 +26,13 @@ interface PodcastCardProps {
progressPercent?: number
}
export function PodcastCard({ podcast, progressPercent }: PodcastCardProps) {
export function PodcastCard({ podcast, progressPercent: propProgress }: PodcastCardProps) {
const play = usePlayerStore((s) => s.play)
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
return (
<article className="group lift bg-surface rounded-3xl shadow-organic-sm hover:shadow-organic overflow-hidden">

View File

@ -0,0 +1,57 @@
import { useEffect, useState } from 'react'
import { supabase } from '@/lib/supabase'
import { useAuthStore } from '@/stores/auth'
type ProgressMap = Map<string, { seconds: number; percent: number }>
let cachedProgress: ProgressMap = new Map()
let lastFetch = 0
export function useListeningProgress() {
const user = useAuthStore((s) => s.user)
const [progress, setProgress] = useState<ProgressMap>(cachedProgress)
useEffect(() => {
if (!user) {
cachedProgress = new Map()
setProgress(cachedProgress)
return
}
// Refetch at most every 10s
const now = Date.now()
if (now - lastFetch < 10_000 && cachedProgress.size > 0) {
setProgress(cachedProgress)
return
}
supabase
.from('listen_history')
.select('podcast_id, progress_seconds, podcast:podcasts(duration_seconds)')
.eq('user_id', user.id)
.eq('completed', false)
.gt('progress_seconds', 5)
.then(({ data }) => {
const map: ProgressMap = new Map()
if (data) {
for (const row of data) {
const dur = (row as any).podcast?.duration_seconds || 0
const pct = dur > 0 ? (row.progress_seconds / dur) * 100 : 0
if (pct > 0 && pct < 95) {
map.set(row.podcast_id, { seconds: row.progress_seconds, percent: pct })
}
}
}
cachedProgress = map
lastFetch = Date.now()
setProgress(map)
})
}, [user])
return progress
}
/** Invalidate cache so next render refetches */
export function invalidateProgressCache() {
lastFetch = 0
}

View File

@ -10,6 +10,7 @@ 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'
export function PodcastDetail() {
const { id } = useParams<{ id: string }>()
@ -23,6 +24,8 @@ export function PodcastDetail() {
const [loading, setLoading] = useState(true)
const isActive = current?.id === id
const progressMap = useListeningProgress()
const progressInfo = id ? progressMap.get(id) : undefined
useEffect(() => {
if (!id) return
@ -141,13 +144,22 @@ export function PodcastDetail() {
</div>
)}
<div className="flex items-center gap-2 mt-5">
{progressInfo && progressInfo.percent > 0 && (
<div className="mt-5">
<div className="h-1.5 bg-border-light rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full transition-all" style={{ width: `${Math.min(progressInfo.percent, 100)}%` }} />
</div>
<p className="text-xs text-text-secondary mt-1">{formatDuration(progressInfo.seconds)} / {formatDuration(podcast.duration_seconds)}</p>
</div>
)}
<div className="flex items-center gap-2 mt-3">
<Button
onClick={() => isActive ? toggle() : play(podcast)}
size="lg"
>
{isActive && isPlaying ? <Pause size={18} /> : <Play size={18} className="ml-0.5" />}
{isActive && isPlaying ? 'Pause' : 'Écouter'}
{isActive && isPlaying ? 'Pause' : progressInfo && !isActive ? `Reprendre à ${formatDuration(progressInfo.seconds)}` : 'Écouter'}
</Button>
<Button
variant={isLiked ? 'accent' : 'secondary'}

View File

@ -4,6 +4,7 @@ import { isExternalUrl, getEmbedInfo } from '@/lib/embed'
import { publicUrl } from '@/lib/storage'
import { supabase } from '@/lib/supabase'
import { useAuthStore } from '@/stores/auth'
import { invalidateProgressCache } from '@/hooks/useListeningProgress'
// ──── YouTube IFrame API ────
declare global {
@ -84,7 +85,10 @@ async function saveProgressNow(podcastId: string, seconds: number, duration: num
{ onConflict: 'user_id,podcast_id' }
)
if (error) console.error('[player] save progress failed:', error)
else console.log('[player] saved progress:', podcastId, Math.floor(seconds), 'seconds')
else {
console.log('[player] saved progress:', podcastId, Math.floor(seconds), 'seconds')
invalidateProgressCache()
}
}
function debouncedSaveProgress(podcastId: string, seconds: number, duration: number) {