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

This commit is contained in:
ordinarthur 2026-04-13 10:49:27 +02:00
parent 95a7e744ff
commit 143e453f78
4 changed files with 199 additions and 52 deletions

View File

@ -22,9 +22,10 @@ function titleToGradient(title: string) {
interface PodcastCardProps { interface PodcastCardProps {
podcast: Podcast podcast: Podcast
progressPercent?: number
} }
export function PodcastCard({ podcast }: PodcastCardProps) { export function PodcastCard({ podcast, progressPercent }: PodcastCardProps) {
const play = usePlayerStore((s) => s.play) const play = usePlayerStore((s) => s.play)
const currentId = usePlayerStore((s) => s.current?.id) const currentId = usePlayerStore((s) => s.current?.id)
const isPlaying = usePlayerStore((s) => s.isPlaying) const isPlaying = usePlayerStore((s) => s.isPlaying)
@ -76,6 +77,13 @@ export function PodcastCard({ podcast }: PodcastCardProps) {
<div className="wave-bar" /><div className="wave-bar" /><div className="wave-bar" /><div className="wave-bar" /> <div className="wave-bar" /><div className="wave-bar" /><div className="wave-bar" /><div className="wave-bar" />
</div> </div>
)} )}
{/* Listening progress bar */}
{progressPercent != null && progressPercent > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/20">
<div className="h-full bg-primary rounded-r-full" style={{ width: `${Math.min(progressPercent, 100)}%` }} />
</div>
)}
</div> </div>
{/* Info */} {/* Info */}

View File

@ -1,20 +1,27 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' 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 { supabase } from '@/lib/supabase'
import { useAuthStore } from '@/stores/auth'
import type { Podcast } from '@/types' import type { Podcast } from '@/types'
import { PodcastCard } from '@/components/podcast/PodcastCard' import { PodcastCard } from '@/components/podcast/PodcastCard'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
interface InProgressPodcast extends Podcast {
progressPercent: number
}
export function Home() { export function Home() {
const [trending, setTrending] = useState<Podcast[]>([]) const [trending, setTrending] = useState<Podcast[]>([])
const [recent, setRecent] = useState<Podcast[]>([]) const [recent, setRecent] = useState<Podcast[]>([])
const [inProgress, setInProgress] = useState<InProgressPodcast[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const { user } = useAuthStore()
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => { useEffect(() => {
async function load() { async function load() {
const [trendingRes, recentRes] = await Promise.all([ const queries: Promise<any>[] = [
supabase supabase
.from('podcasts') .from('podcasts')
.select('*, creator:profiles!podcasts_creator_id_fkey(*), tags:podcast_tags(tag:tags(*))') .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(*))') .select('*, creator:profiles!podcasts_creator_id_fkey(*), tags:podcast_tags(tag:tags(*))')
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.limit(8), .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) setLoading(false)
} }
load() load()
}, []) }, [user])
return ( return (
<div className="space-y-14"> <div className="space-y-14">
@ -86,6 +122,24 @@ export function Home() {
</div> </div>
</section> </section>
{/* Continue listening */}
{inProgress.length > 0 && (
<section>
<div className="mb-5">
<h2 className="flex items-center gap-2 text-xl font-heading font-extrabold">
<PlayCircle size={20} strokeWidth={2.5} />
Reprendre l'ecoute
</h2>
<p className="text-[13px] text-text-secondary mt-0.5">La ou vous vous etes arrete</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-5">
{inProgress.map((p) => (
<PodcastCard key={p.id} podcast={p} progressPercent={p.progressPercent} />
))}
</div>
</section>
)}
<PodcastSection <PodcastSection
icon={<TrendingUp size={20} strokeWidth={2.5} />} icon={<TrendingUp size={20} strokeWidth={2.5} />}
title="Tendances" title="Tendances"

View File

@ -2,6 +2,8 @@ import { create } from 'zustand'
import type { Podcast } from '@/types' import type { Podcast } from '@/types'
import { isExternalUrl, getEmbedInfo } from '@/lib/embed' import { isExternalUrl, getEmbedInfo } from '@/lib/embed'
import { publicUrl } from '@/lib/storage' import { publicUrl } from '@/lib/storage'
import { supabase } from '@/lib/supabase'
import { useAuthStore } from '@/stores/auth'
// ──── YouTube IFrame API ──── // ──── YouTube IFrame API ────
declare global { declare global {
@ -69,6 +71,41 @@ function createHiddenIframe(embedUrl: string) {
hiddenIframe = iframe hiddenIframe = iframe
} }
// ──── Progress persistence ────
let saveTimeout: ReturnType<typeof setTimeout> | null = null
async function saveProgressNow(podcastId: string, seconds: number, duration: number, markCompleted = false): Promise<void> {
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<number> {
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 ──── // ──── Store ────
interface PlayerState { interface PlayerState {
current: Podcast | null current: Podcast | null
@ -102,7 +139,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
const external = isExternalUrl(podcast.audio_url) const external = isExternalUrl(podcast.audio_url)
const embed = external ? getEmbedInfo(podcast.audio_url) : null const embed = external ? getEmbedInfo(podcast.audio_url) : null
// Resume same podcast // Resume same podcast (already loaded)
if (current?.id === podcast.id) { if (current?.id === podcast.id) {
if (external && embed?.platform === 'youtube' && ytPlayer) { if (external && embed?.platform === 'youtube' && ytPlayer) {
try { ytPlayer.playVideo() } catch { /* ignore */ } try { ytPlayer.playVideo() } catch { /* ignore */ }
@ -122,7 +159,11 @@ export const usePlayerStore = create<PlayerState>((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') } if (audio) { audio.pause(); audio.removeAttribute('src') }
destroyAll() destroyAll()
@ -130,8 +171,9 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
if (external && embed?.platform === 'youtube') { if (external && embed?.platform === 'youtube') {
set({ audio: null, current: podcast, isPlaying: false, isExternal: true, progress: 0, duration: podcast.duration_seconds || 0 }) set({ audio: null, current: podcast, isPlaying: false, isExternal: true, progress: 0, duration: podcast.duration_seconds || 0 })
// Wait for previous save to complete, then fetch saved progress
savePrevPromise.then(() => fetchSavedProgress(podcast.id)).then((savedTime) => {
loadYouTubeAPI().then(() => { loadYouTubeAPI().then(() => {
// Create hidden container
ytContainer = document.createElement('div') ytContainer = document.createElement('div')
ytContainer.style.cssText = 'position:fixed;width:1px;height:1px;left:-10px;top:-10px;opacity:0;pointer-events:none;' ytContainer.style.cssText = 'position:fixed;width:1px;height:1px;left:-10px;top:-10px;opacity:0;pointer-events:none;'
const playerDiv = document.createElement('div') const playerDiv = document.createElement('div')
@ -148,6 +190,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
fs: 0, fs: 0,
modestbranding: 1, modestbranding: 1,
rel: 0, rel: 0,
start: savedTime > 0 ? Math.floor(savedTime) : undefined,
}, },
events: { events: {
onReady: (e: any) => { onReady: (e: any) => {
@ -155,13 +198,19 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
e.target.playVideo() e.target.playVideo()
const dur = e.target.getDuration() const dur = e.target.getDuration()
if (dur > 0) set({ duration: dur }) if (dur > 0) set({ duration: dur })
if (savedTime > 0) {
e.target.seekTo(savedTime, true)
set({ progress: savedTime })
}
set({ isPlaying: true }) set({ isPlaying: true })
startYtProgressTracking() startYtProgressTracking()
}, },
onStateChange: (e: any) => { onStateChange: (e: any) => {
// YT.PlayerState: 0=ENDED, 1=PLAYING, 2=PAUSED
if (e.data === 0) { if (e.data === 0) {
// Ended
clearProgressInterval() clearProgressInterval()
const podId = usePlayerStore.getState().current?.id
if (podId) saveProgressNow(podId, e.target.getDuration(), e.target.getDuration(), true)
set({ isPlaying: false, progress: 0 }) set({ isPlaying: false, progress: 0 })
} else if (e.data === 1) { } else if (e.data === 1) {
const dur = e.target.getDuration() const dur = e.target.getDuration()
@ -170,12 +219,16 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
startYtProgressTracking() startYtProgressTracking()
} else if (e.data === 2) { } else if (e.data === 2) {
clearProgressInterval() clearProgressInterval()
// Save on pause
const state = usePlayerStore.getState()
if (state.current) saveProgressNow(state.current.id, state.progress, state.duration)
set({ isPlaying: false }) set({ isPlaying: false })
} }
}, },
}, },
}) })
}) })
})
return return
} }
@ -192,11 +245,20 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
newAudio.addEventListener('timeupdate', () => { newAudio.addEventListener('timeupdate', () => {
set({ progress: newAudio.currentTime }) set({ progress: newAudio.currentTime })
debouncedSaveProgress(podcast.id, newAudio.currentTime, newAudio.duration || 0)
}) })
newAudio.addEventListener('loadedmetadata', () => { newAudio.addEventListener('loadedmetadata', () => {
set({ duration: newAudio.duration }) 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', () => { newAudio.addEventListener('ended', () => {
saveProgressNow(podcast.id, newAudio.duration, newAudio.duration, true)
set({ isPlaying: false, progress: 0 }) set({ isPlaying: false, progress: 0 })
}) })
@ -205,17 +267,17 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
}, },
toggle: () => { toggle: () => {
const { audio, isPlaying, isExternal, current } = get() const { audio, isPlaying, isExternal, current, progress, duration } = get()
if (isExternal) { if (isExternal) {
const embed = current ? getEmbedInfo(current.audio_url) : null const embed = current ? getEmbedInfo(current.audio_url) : null
// YouTube: proper pause/play
if (embed?.platform === 'youtube' && ytPlayer) { if (embed?.platform === 'youtube' && ytPlayer) {
try { try {
if (isPlaying) { if (isPlaying) {
ytPlayer.pauseVideo() ytPlayer.pauseVideo()
clearProgressInterval() clearProgressInterval()
if (current) saveProgressNow(current.id, progress, duration)
} else { } else {
ytPlayer.playVideo() ytPlayer.playVideo()
startYtProgressTracking() startYtProgressTracking()
@ -225,7 +287,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
return return
} }
// Other external: destroy/recreate iframe
if (isPlaying) { if (isPlaying) {
destroyIframe() destroyIframe()
} else if (embed) { } else if (embed) {
@ -238,6 +299,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
if (!audio) return if (!audio) return
if (isPlaying) { if (isPlaying) {
audio.pause() audio.pause()
if (current) saveProgressNow(current.id, progress, duration)
} else { } else {
audio.play() audio.play()
} }
@ -245,7 +307,9 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
}, },
pause: () => { pause: () => {
const { isExternal, current } = get() const { isExternal, current, progress, duration } = get()
if (current) saveProgressNow(current.id, progress, duration)
if (isExternal) { if (isExternal) {
const embed = current ? getEmbedInfo(current.audio_url) : null const embed = current ? getEmbedInfo(current.audio_url) : null
if (embed?.platform === 'youtube' && ytPlayer) { if (embed?.platform === 'youtube' && ytPlayer) {
@ -262,7 +326,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
seek: (time) => { seek: (time) => {
const { audio, isExternal, current } = get() const { audio, isExternal, current } = get()
// YouTube seek
if (isExternal) { if (isExternal) {
const embed = current ? getEmbedInfo(current.audio_url) : null const embed = current ? getEmbedInfo(current.audio_url) : null
if (embed?.platform === 'youtube' && ytPlayer) { if (embed?.platform === 'youtube' && ytPlayer) {
@ -270,7 +333,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
set({ progress: time }) set({ progress: time })
return return
} }
// Can't seek other external types
return return
} }
@ -300,6 +362,8 @@ function startYtProgressTracking() {
const dur = ytPlayer.getDuration() const dur = ytPlayer.getDuration()
usePlayerStore.setState({ progress: time }) usePlayerStore.setState({ progress: time })
if (dur > 0) usePlayerStore.setState({ duration: dur }) if (dur > 0) usePlayerStore.setState({ duration: dur })
const podId = usePlayerStore.getState().current?.id
if (podId) debouncedSaveProgress(podId, time, dur)
} catch { /* ignore */ } } catch { /* ignore */ }
} }
}, 500) }, 500)

View File

@ -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;