add progression
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 11s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 11s
This commit is contained in:
parent
95a7e744ff
commit
143e453f78
@ -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) {
|
||||
<div className="wave-bar" /><div className="wave-bar" /><div className="wave-bar" /><div className="wave-bar" />
|
||||
</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>
|
||||
|
||||
{/* Info */}
|
||||
|
||||
@ -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<Podcast[]>([])
|
||||
const [recent, setRecent] = useState<Podcast[]>([])
|
||||
const [inProgress, setInProgress] = useState<InProgressPodcast[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { user } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [trendingRes, recentRes] = await Promise.all([
|
||||
const queries: Promise<any>[] = [
|
||||
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 (
|
||||
<div className="space-y-14">
|
||||
@ -86,6 +122,24 @@ export function Home() {
|
||||
</div>
|
||||
</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
|
||||
icon={<TrendingUp size={20} strokeWidth={2.5} />}
|
||||
title="Tendances"
|
||||
|
||||
@ -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<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 ────
|
||||
interface PlayerState {
|
||||
current: Podcast | null
|
||||
@ -102,7 +139,7 @@ export const usePlayerStore = create<PlayerState>((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<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') }
|
||||
destroyAll()
|
||||
|
||||
@ -130,8 +171,9 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
if (external && embed?.platform === 'youtube') {
|
||||
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(() => {
|
||||
// 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')
|
||||
@ -148,6 +190,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
fs: 0,
|
||||
modestbranding: 1,
|
||||
rel: 0,
|
||||
start: savedTime > 0 ? Math.floor(savedTime) : undefined,
|
||||
},
|
||||
events: {
|
||||
onReady: (e: any) => {
|
||||
@ -155,13 +198,19 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
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()
|
||||
},
|
||||
onStateChange: (e: any) => {
|
||||
// YT.PlayerState: 0=ENDED, 1=PLAYING, 2=PAUSED
|
||||
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()
|
||||
@ -170,12 +219,16 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
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<PlayerState>((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<PlayerState>((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<PlayerState>((set, get) => ({
|
||||
return
|
||||
}
|
||||
|
||||
// Other external: destroy/recreate iframe
|
||||
if (isPlaying) {
|
||||
destroyIframe()
|
||||
} else if (embed) {
|
||||
@ -238,6 +299,7 @@ export const usePlayerStore = create<PlayerState>((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<PlayerState>((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<PlayerState>((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<PlayerState>((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)
|
||||
|
||||
21
supabase/migrations/003_listening_progress.sql
Normal file
21
supabase/migrations/003_listening_progress.sql
Normal 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;
|
||||
Loading…
x
Reference in New Issue
Block a user