From 143e453f78f4e4510c4d825d19b7b6db5f274614 Mon Sep 17 00:00:00 2001
From: ordinarthur <@arthurbarre.js@gmail.com>
Date: Mon, 13 Apr 2026 10:49:27 +0200
Subject: [PATCH] add progression
---
src/components/podcast/PodcastCard.tsx | 10 +-
src/pages/Home.tsx | 66 +++++++-
src/stores/player.ts | 154 +++++++++++++-----
.../migrations/003_listening_progress.sql | 21 +++
4 files changed, 199 insertions(+), 52 deletions(-)
create mode 100644 supabase/migrations/003_listening_progress.sql
diff --git a/src/components/podcast/PodcastCard.tsx b/src/components/podcast/PodcastCard.tsx
index dc05ef6..8d31199 100644
--- a/src/components/podcast/PodcastCard.tsx
+++ b/src/components/podcast/PodcastCard.tsx
@@ -22,9 +22,10 @@ function titleToGradient(title: string) {
interface PodcastCardProps {
podcast: Podcast
+ progressPercent?: number
}
-export function PodcastCard({ podcast }: PodcastCardProps) {
+export function PodcastCard({ podcast, progressPercent }: PodcastCardProps) {
const play = usePlayerStore((s) => s.play)
const currentId = usePlayerStore((s) => s.current?.id)
const isPlaying = usePlayerStore((s) => s.isPlaying)
@@ -76,6 +77,13 @@ export function PodcastCard({ podcast }: PodcastCardProps) {
)}
+
+ {/* Listening progress bar */}
+ {progressPercent != null && progressPercent > 0 && (
+
+ )}
{/* Info */}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index 91761df..82f82a0 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -1,20 +1,27 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
-import { TrendingUp, Clock, ArrowRight, Headphones, Users, Mic } from 'lucide-react'
+import { TrendingUp, Clock, ArrowRight, Headphones, Users, Mic, PlayCircle } from 'lucide-react'
import { supabase } from '@/lib/supabase'
+import { useAuthStore } from '@/stores/auth'
import type { Podcast } from '@/types'
import { PodcastCard } from '@/components/podcast/PodcastCard'
import { Button } from '@/components/ui/Button'
+interface InProgressPodcast extends Podcast {
+ progressPercent: number
+}
+
export function Home() {
const [trending, setTrending] = useState([])
const [recent, setRecent] = useState([])
+ const [inProgress, setInProgress] = useState([])
const [loading, setLoading] = useState(true)
+ const { user } = useAuthStore()
const navigate = useNavigate()
useEffect(() => {
async function load() {
- const [trendingRes, recentRes] = await Promise.all([
+ const queries: Promise[] = [
supabase
.from('podcasts')
.select('*, creator:profiles!podcasts_creator_id_fkey(*), tags:podcast_tags(tag:tags(*))')
@@ -25,14 +32,43 @@ export function Home() {
.select('*, creator:profiles!podcasts_creator_id_fkey(*), tags:podcast_tags(tag:tags(*))')
.order('created_at', { ascending: false })
.limit(8),
- ])
+ ]
+
+ // Fetch in-progress podcasts if logged in
+ if (user) {
+ queries.push(
+ supabase
+ .from('listen_history')
+ .select('progress_seconds, podcast:podcasts(*, creator:profiles!podcasts_creator_id_fkey(*), tags:podcast_tags(tag:tags(*)))')
+ .eq('user_id', user.id)
+ .eq('completed', false)
+ .gt('progress_seconds', 5)
+ .order('listened_at', { ascending: false })
+ .limit(6)
+ )
+ }
+
+ const results = await Promise.all(queries)
+
+ if (results[0].data) setTrending(normalizePodcasts(results[0].data))
+ if (results[1].data) setRecent(normalizePodcasts(results[1].data))
+
+ if (user && results[2]?.data) {
+ const items = results[2].data
+ .filter((r: any) => r.podcast)
+ .map((r: any) => {
+ const p = normalizePodcasts([r.podcast])[0]
+ const pct = p.duration_seconds > 0 ? (r.progress_seconds / p.duration_seconds) * 100 : 0
+ return { ...p, progressPercent: pct }
+ })
+ .filter((p: InProgressPodcast) => p.progressPercent > 0 && p.progressPercent < 95)
+ setInProgress(items)
+ }
- if (trendingRes.data) setTrending(normalizePodcasts(trendingRes.data))
- if (recentRes.data) setRecent(normalizePodcasts(recentRes.data))
setLoading(false)
}
load()
- }, [])
+ }, [user])
return (
@@ -86,6 +122,24 @@ export function Home() {
+ {/* Continue listening */}
+ {inProgress.length > 0 && (
+
+
+
+
+ Reprendre l'ecoute
+
+
La ou vous vous etes arrete
+
+
+ {inProgress.map((p) => (
+
+ ))}
+
+
+ )}
+
}
title="Tendances"
diff --git a/src/stores/player.ts b/src/stores/player.ts
index 6760bc8..3454585 100644
--- a/src/stores/player.ts
+++ b/src/stores/player.ts
@@ -2,6 +2,8 @@ import { create } from 'zustand'
import type { Podcast } from '@/types'
import { isExternalUrl, getEmbedInfo } from '@/lib/embed'
import { publicUrl } from '@/lib/storage'
+import { supabase } from '@/lib/supabase'
+import { useAuthStore } from '@/stores/auth'
// ──── YouTube IFrame API ────
declare global {
@@ -69,6 +71,41 @@ function createHiddenIframe(embedUrl: string) {
hiddenIframe = iframe
}
+// ──── 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
+ const completed = markCompleted || (duration > 0 && seconds / duration > 0.95)
+ 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' }
+ )
+ if (error) console.error('[player] save progress failed:', error)
+ else console.log('[player] saved progress:', podcastId, Math.floor(seconds), 'seconds')
+}
+
+function debouncedSaveProgress(podcastId: string, seconds: number, duration: number) {
+ if (saveTimeout) clearTimeout(saveTimeout)
+ saveTimeout = setTimeout(() => saveProgressNow(podcastId, seconds, duration), 5000)
+}
+
+async function fetchSavedProgress(podcastId: string): Promise {
+ 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
+}
+
// ──── Store ────
interface PlayerState {
current: Podcast | null
@@ -102,7 +139,7 @@ export const usePlayerStore = create((set, get) => ({
const external = isExternalUrl(podcast.audio_url)
const embed = external ? getEmbedInfo(podcast.audio_url) : null
- // Resume same podcast
+ // Resume same podcast (already loaded)
if (current?.id === podcast.id) {
if (external && embed?.platform === 'youtube' && ytPlayer) {
try { ytPlayer.playVideo() } catch { /* ignore */ }
@@ -122,7 +159,11 @@ export const usePlayerStore = create((set, get) => ({
}
}
- // Stop previous
+ // Stop & save previous
+ const prev = get().current
+ const savePrevPromise = prev
+ ? saveProgressNow(prev.id, get().progress, get().duration)
+ : Promise.resolve()
if (audio) { audio.pause(); audio.removeAttribute('src') }
destroyAll()
@@ -130,50 +171,62 @@ export const usePlayerStore = create((set, get) => ({
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)
+ // Wait for previous save to complete, then fetch saved progress
+ savePrevPromise.then(() => fetchSavedProgress(podcast.id)).then((savedTime) => {
+ loadYouTubeAPI().then(() => {
+ 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()
+ ytPlayer = new window.YT.Player(playerDiv.id, {
+ videoId: embed.id,
+ playerVars: {
+ autoplay: 1,
+ controls: 0,
+ disablekb: 1,
+ fs: 0,
+ modestbranding: 1,
+ rel: 0,
+ start: savedTime > 0 ? Math.floor(savedTime) : undefined,
},
- 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) {
+ events: {
+ onReady: (e: any) => {
+ e.target.setVolume(volume * 100)
+ e.target.playVideo()
const dur = e.target.getDuration()
if (dur > 0) set({ duration: dur })
+ if (savedTime > 0) {
+ e.target.seekTo(savedTime, true)
+ set({ progress: savedTime })
+ }
set({ isPlaying: true })
startYtProgressTracking()
- } else if (e.data === 2) {
- clearProgressInterval()
- set({ isPlaying: false })
- }
+ },
+ onStateChange: (e: any) => {
+ if (e.data === 0) {
+ // Ended
+ clearProgressInterval()
+ const podId = usePlayerStore.getState().current?.id
+ if (podId) saveProgressNow(podId, e.target.getDuration(), e.target.getDuration(), true)
+ 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()
+ // Save on pause
+ const state = usePlayerStore.getState()
+ if (state.current) saveProgressNow(state.current.id, state.progress, state.duration)
+ set({ isPlaying: false })
+ }
+ },
},
- },
+ })
})
})
return
@@ -192,11 +245,20 @@ export const usePlayerStore = create((set, get) => ({
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 })
+ }
+ })
})
newAudio.addEventListener('ended', () => {
+ saveProgressNow(podcast.id, newAudio.duration, newAudio.duration, true)
set({ isPlaying: false, progress: 0 })
})
@@ -205,17 +267,17 @@ export const usePlayerStore = create((set, get) => ({
},
toggle: () => {
- const { audio, isPlaying, isExternal, current } = get()
+ const { audio, isPlaying, isExternal, current, progress, duration } = 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()
+ if (current) saveProgressNow(current.id, progress, duration)
} else {
ytPlayer.playVideo()
startYtProgressTracking()
@@ -225,7 +287,6 @@ export const usePlayerStore = create((set, get) => ({
return
}
- // Other external: destroy/recreate iframe
if (isPlaying) {
destroyIframe()
} else if (embed) {
@@ -238,6 +299,7 @@ export const usePlayerStore = create((set, get) => ({
if (!audio) return
if (isPlaying) {
audio.pause()
+ if (current) saveProgressNow(current.id, progress, duration)
} else {
audio.play()
}
@@ -245,7 +307,9 @@ export const usePlayerStore = create((set, get) => ({
},
pause: () => {
- const { isExternal, current } = get()
+ const { isExternal, current, progress, duration } = get()
+ if (current) saveProgressNow(current.id, progress, duration)
+
if (isExternal) {
const embed = current ? getEmbedInfo(current.audio_url) : null
if (embed?.platform === 'youtube' && ytPlayer) {
@@ -262,7 +326,6 @@ export const usePlayerStore = create((set, get) => ({
seek: (time) => {
const { audio, isExternal, current } = get()
- // YouTube seek
if (isExternal) {
const embed = current ? getEmbedInfo(current.audio_url) : null
if (embed?.platform === 'youtube' && ytPlayer) {
@@ -270,7 +333,6 @@ export const usePlayerStore = create((set, get) => ({
set({ progress: time })
return
}
- // Can't seek other external types
return
}
@@ -300,6 +362,8 @@ function startYtProgressTracking() {
const dur = ytPlayer.getDuration()
usePlayerStore.setState({ progress: time })
if (dur > 0) usePlayerStore.setState({ duration: dur })
+ const podId = usePlayerStore.getState().current?.id
+ if (podId) debouncedSaveProgress(podId, time, dur)
} catch { /* ignore */ }
}
}, 500)
diff --git a/supabase/migrations/003_listening_progress.sql b/supabase/migrations/003_listening_progress.sql
new file mode 100644
index 0000000..c990803
--- /dev/null
+++ b/supabase/migrations/003_listening_progress.sql
@@ -0,0 +1,21 @@
+-- Add progress tracking to listen_history
+ALTER TABLE public.listen_history
+ ADD COLUMN IF NOT EXISTS progress_seconds integer NOT NULL DEFAULT 0,
+ ADD COLUMN IF NOT EXISTS completed boolean NOT NULL DEFAULT false;
+
+-- Unique constraint for upsert (one row per user+podcast)
+ALTER TABLE public.listen_history
+ ADD CONSTRAINT listen_history_user_podcast_unique UNIQUE (user_id, podcast_id);
+
+-- Allow users to update their own progress
+CREATE POLICY "Users can update own history" ON public.listen_history
+ FOR UPDATE USING (auth.uid() = user_id);
+
+-- Allow users to delete own history
+CREATE POLICY "Users can delete own history" ON public.listen_history
+ FOR DELETE USING (auth.uid() = user_id);
+
+-- Index for "continue listening" query
+CREATE INDEX IF NOT EXISTS idx_listen_history_resume
+ ON public.listen_history(user_id, listened_at DESC)
+ WHERE NOT completed;