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;