fix: audio persistence across navigation + LU badge at 90% + filter continuer section

- AudioPlayer is now purely presentational — no internal <audio> element or state.
  All playback state (currentTime, duration, volume, speed, mute, error, isResuming)
  comes from AudioContext, which holds the persistent <audio> at provider level.
  This prevents audio from cutting when navigating between pages.

- userId is synced into AudioContext via setUserId() so progress saving/loading
  works without the player needing to manage it locally.

- EpisodeCard: isCompleted now triggers at ≥90% listened (progressPercent >= 0.9)
  in addition to the isCompleted flag, so the LU badge appears correctly.
  Progress bar also hidden when ≥90%.

- Dashboard: episodesInProgress now excludes episodes at ≥90% listened,
  removing them from the "Continuer l'écoute" section once effectively finished.
  setUserId() called after user loads to wire context progress saving.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Sonnet 4.6 committed 2026-04-17 15:08:04 +02:00
1 parent 842ead6518
commit 5a9a0904a1
4 files changed
+276 -245

No files matched your search

+117 -224
View File
@@ -1,7 +1,6 @@
import { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Episode } from '../services/episodeService';
import { episodeService } from '../services/episodeService';
import { useAudio } from '../context/AudioContext';
import {
Play,
@@ -13,7 +12,6 @@ import {
X,
RotateCcw,
RotateCw,
AlertCircle
} from 'lucide-react';
import AlertModal from './AlertModal';
import { useTheme } from '../context/ThemeContext';
@@ -26,71 +24,34 @@ interface AudioPlayerProps {
}
export default function AudioPlayer({ episode, onClose, userId, mode = 'floating' }: AudioPlayerProps) {
const { isPlaying, setIsPlaying, togglePlay } = useAudio();
const {
isPlaying,
currentTime,
duration,
volume,
playbackSpeed,
isMuted,
isResuming,
error,
togglePlay,
seek,
seekRelative,
setVolume,
toggleMute,
changeSpeed,
setError,
setUserId,
} = useAudio();
const { theme } = useTheme();
const audioRef = useRef<HTMLAudioElement>(null);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(() => parseFloat(localStorage.getItem('podcastic-volume') || '1'));
const [playbackSpeed, setPlaybackSpeed] = useState(() => parseFloat(localStorage.getItem('podcastic-speed') || '1'));
const [isMuted, setIsMuted] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [isResuming, setIsResuming] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showMiniBar, setShowMiniBar] = useState(false);
const inlineWrapperRef = useRef<HTMLDivElement>(null);
// Refs for stable tracking without re-renders
const currentTimeRef = useRef(0);
const episodeIdRef = useRef<string | null>(null);
const userIdRef = useRef<string | null>(null);
// Sync refs with state
// Sync userId into context so context can save/load progress
useEffect(() => {
currentTimeRef.current = currentTime;
}, [currentTime]);
useEffect(() => {
episodeIdRef.current = episode?._id || null;
userIdRef.current = userId || null;
}, [episode, userId]);
const podcast = typeof episode?.podcastId === 'object' ? episode.podcastId : null;
// Sync settings to audio element
useEffect(() => {
if (audioRef.current) {
audioRef.current.volume = isMuted ? 0 : volume;
audioRef.current.playbackRate = playbackSpeed;
}
}, [volume, isMuted, playbackSpeed, episode]);
// Save settings
useEffect(() => {
localStorage.setItem('podcastic-volume', volume.toString());
localStorage.setItem('podcastic-speed', playbackSpeed.toString());
}, [volume, playbackSpeed]);
// Save progress periodically and on unmount
useEffect(() => {
if (!episode || !userId) return;
// Save every 20 seconds only if playing
const interval = setInterval(() => {
if (isPlaying && episodeIdRef.current) {
episodeService.saveProgress(episodeIdRef.current, Math.floor(currentTimeRef.current), false).catch(() => {});
}
}, 20000);
// Cleanup: SAVE FINAL PROGRESS ON UNMOUNT OR EPISODE CHANGE
return () => {
clearInterval(interval);
if (episodeIdRef.current && userIdRef.current) {
console.log(`[AudioPlayer] Final save for ${episodeIdRef.current} at ${Math.floor(currentTimeRef.current)}s`);
episodeService.saveProgress(episodeIdRef.current, Math.floor(currentTimeRef.current), false).catch(() => {});
}
};
}, [episode?._id, userId, isPlaying]); // Restart if episode or isPlaying changes to maintain correct interval behavior
setUserId(userId ?? null);
}, [userId, setUserId]);
// IntersectionObserver: show mini-bar when inline player scrolls out of view
useEffect(() => {
@@ -105,58 +66,7 @@ export default function AudioPlayer({ episode, onClose, userId, mode = 'floating
return () => observer.disconnect();
}, [mode, episode?._id]);
// Load previous progress
useEffect(() => {
if (!episode || !userId) return;
setIsResuming(true);
episodeService.getProgress(episode._id).then((r) => {
if (r.progress?.position && audioRef.current) {
console.log(`[AudioPlayer] Resuming at ${r.progress.position}s`);
audioRef.current.currentTime = r.progress.position;
setCurrentTime(r.progress.position);
}
setIsResuming(false);
}).catch(() => {
setIsResuming(false);
});
}, [episode, userId]);
const handlePlayPause = () => {
if (!audioRef.current) return;
if (isPlaying) audioRef.current.pause();
else audioRef.current.play();
};
const changeSpeed = () => {
const speeds = [1, 1.25, 1.5, 2, 0.75];
const nextIndex = (speeds.indexOf(playbackSpeed) + 1) % speeds.length;
setPlaybackSpeed(speeds[nextIndex]);
};
const handleSeek = (delta: number) => {
if (!audioRef.current) return;
audioRef.current.currentTime = Math.max(0, Math.min(duration, currentTime + delta));
};
const handleProgressChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const t = parseFloat(e.target.value);
setCurrentTime(t);
if (audioRef.current) audioRef.current.currentTime = t;
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = parseFloat(e.target.value);
setVolume(v);
setIsMuted(v === 0);
if (audioRef.current) audioRef.current.volume = v;
};
const toggleMute = () => {
if (!audioRef.current) return;
const muted = !isMuted;
setIsMuted(muted);
audioRef.current.volume = muted ? 0 : volume;
};
const podcast = typeof episode?.podcastId === 'object' ? episode.podcastId : null;
const formatTime = (s: number) => {
if (!isFinite(s)) return '0:00';
@@ -168,26 +78,21 @@ export default function AudioPlayer({ episode, onClose, userId, mode = 'floating
if (!episode) return null;
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
const inverseTheme = theme === 'light' ? 'dark-theme' : 'light-theme';
const handleProgressChange = (e: React.ChangeEvent<HTMLInputElement>) => {
seek(parseFloat(e.target.value));
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setVolume(parseFloat(e.target.value));
};
// ── INLINE MODE (embedded in hero card) ──
if (mode === 'inline') {
return (
<>
<div ref={inlineWrapperRef} className="premium-glass rounded-[var(--radius-panel)] p-8 lg:p-10 relative overflow-hidden border border-[var(--border-color)]">
<audio
ref={audioRef}
src={episode.audioUrl}
onTimeUpdate={() => audioRef.current && setCurrentTime(audioRef.current.currentTime)}
onLoadedMetadata={() => audioRef.current && setDuration(audioRef.current.duration)}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
onError={() => setError("Impossible de charger ce fichier audio. Le lien est peut-être expiré ou protégé.")}
autoPlay
/>
<AlertModal
isOpen={!!error}
title="Erreur de lecture"
@@ -264,16 +169,16 @@ export default function AudioPlayer({ episode, onClose, userId, mode = 'floating
{/* Controls */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-5">
<button onClick={() => handleSeek(-30)}
<button onClick={() => seekRelative(-30)}
className="flex flex-col items-center gap-0.5 text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors">
<RotateCcw className="w-5 h-5" />
<span className="text-[8px] font-black">-30s</span>
</button>
<button onClick={handlePlayPause}
<button onClick={togglePlay}
className="w-14 h-14 rounded-full bg-[var(--accent-primary)] text-white flex items-center justify-center shadow-glow-indigo hover:scale-105 active:scale-95 transition-transform">
{isPlaying ? <Pause className="w-5 h-5 fill-current" /> : <Play className="w-5 h-5 fill-current ml-0.5" />}
</button>
<button onClick={() => handleSeek(30)}
<button onClick={() => seekRelative(30)}
className="flex flex-col items-center gap-0.5 text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors">
<RotateCw className="w-5 h-5" />
<span className="text-[8px] font-black">+30s</span>
@@ -296,7 +201,6 @@ export default function AudioPlayer({ episode, onClose, userId, mode = 'floating
</div>
</div>
</div>
</div>
{/* Mini sticky bar appears when inline player scrolls out of view */}
@@ -320,19 +224,19 @@ export default function AudioPlayer({ episode, onClose, userId, mode = 'floating
{/* Controls */}
<div className="flex items-center gap-3 shrink-0">
<button onClick={() => handleSeek(-30)} className="text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors" aria-label="-30s">
<button onClick={() => seekRelative(-30)} className="text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors" aria-label="-30s">
<RotateCcw className="w-4 h-4" />
</button>
<button onClick={handlePlayPause}
<button onClick={togglePlay}
className="w-10 h-10 rounded-full bg-[var(--accent-primary)] text-white flex items-center justify-center shadow-glow-indigo hover:scale-105 active:scale-95 transition-transform">
{isPlaying ? <Pause className="w-4 h-4 fill-current" /> : <Play className="w-4 h-4 fill-current ml-0.5" />}
</button>
<button onClick={() => handleSeek(30)} className="text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors" aria-label="+30s">
<button onClick={() => seekRelative(30)} className="text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors" aria-label="+30s">
<RotateCw className="w-4 h-4" />
</button>
</div>
{/* Time + close */}
{/* Time + scroll up */}
<div className="hidden md:flex items-center gap-3 shrink-0 text-[10px] text-[var(--text-secondary)] tabular-nums font-bold">
<span>{formatTime(currentTime)}</span>
<span className="opacity-40">/</span>
@@ -365,32 +269,21 @@ export default function AudioPlayer({ episode, onClose, userId, mode = 'floating
);
}
// ── FLOATING MODE (all pages except dashboard) ──
return (
<>
<audio
ref={audioRef}
src={episode.audioUrl}
onTimeUpdate={() => audioRef.current && setCurrentTime(audioRef.current.currentTime)}
onLoadedMetadata={() => audioRef.current && setDuration(audioRef.current.duration)}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
onError={() => setError("Impossible de charger ce fichier audio. Le lien est peut-être expiré ou protégé.")}
autoPlay
/>
<AlertModal
isOpen={!!error}
title="Erreur de lecture"
message={error || ""}
type="error"
onClose={() => {
setError(null);
onClose();
setError(null);
onClose();
}}
/>
{/* ── COMPACT UNIFIED MINI-BAR (same design as Dashboard sticky bar) ── */}
{/* ── COMPACT UNIFIED MINI-BAR ── */}
{!isExpanded && (
<div className="fixed bottom-6 left-4 right-4 sm:left-6 sm:right-6 lg:left-auto lg:right-8 lg:bottom-8 lg:w-[calc(100%-22rem)] xl:w-[calc(100%-24rem)] z-[90] max-w-5xl mx-auto animate-slide-up">
<div className="premium-glass rounded-[var(--radius-card)] border border-[var(--border-color)] shadow-2xl overflow-hidden">
@@ -415,14 +308,14 @@ export default function AudioPlayer({ episode, onClose, userId, mode = 'floating
{/* Controls */}
<div className="flex items-center gap-3 shrink-0">
<button onClick={() => handleSeek(-30)} className="text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors" aria-label="-30s">
<button onClick={() => seekRelative(-30)} className="text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors" aria-label="-30s">
<RotateCcw className="w-4 h-4" />
</button>
<button onClick={handlePlayPause}
<button onClick={togglePlay}
className="w-10 h-10 rounded-full bg-[var(--accent-primary)] text-white flex items-center justify-center shadow-glow-indigo hover:scale-105 active:scale-95 transition-transform">
{isPlaying ? <Pause className="w-4 h-4 fill-current" /> : <Play className="w-4 h-4 fill-current ml-0.5" />}
</button>
<button onClick={() => handleSeek(30)} className="text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors" aria-label="+30s">
<button onClick={() => seekRelative(30)} className="text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors" aria-label="+30s">
<RotateCw className="w-4 h-4" />
</button>
</div>
@@ -469,93 +362,93 @@ export default function AudioPlayer({ episode, onClose, userId, mode = 'floating
</div>
)}
{/* ── EXPANDED VIEW (wrapper for fullscreen) ── */}
{/* ── EXPANDED VIEW ── */}
{isExpanded && (
<div className={`fixed inset-0 z-[100] ${inverseTheme} text-[var(--text-primary)] ${theme === 'light' ? 'bg-gradient-to-r from-slate-800/95 to-slate-900/95 backdrop-blur-xl' : 'premium-glass'} overflow-hidden flex flex-col`}>
<div className="flex-1 flex flex-col p-8 lg:p-16 overflow-y-auto">
{/* Top bar */}
<div className="flex items-center justify-between mb-10">
<button onClick={() => setIsExpanded(false)}
className="p-3 rounded-2xl bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-all">
<ChevronDown className="w-5 h-5" />
</button>
<p className="text-[10px] font-black text-[var(--accent-primary)] uppercase tracking-[0.3em]">En cours de lecture</p>
<button onClick={onClose}
className="p-3 rounded-2xl bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:text-accent-rose transition-all">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex flex-col lg:flex-row gap-12 items-center flex-1">
{/* Artwork */}
<div className="w-64 h-64 lg:w-80 lg:h-80 rounded-[2.5rem] overflow-hidden shadow-2xl border border-[var(--border-color)] shrink-0">
{(episode.imageUrl || podcast?.imageUrl)
? <img src={episode.imageUrl || podcast!.imageUrl} alt="" className="w-full h-full object-cover" />
: <div className="w-full h-full bg-gradient-to-br from-[var(--accent-primary)] to-[var(--accent-secondary)] flex items-center justify-center text-7xl">🎙️</div>
}
{/* Top bar */}
<div className="flex items-center justify-between mb-10">
<button onClick={() => setIsExpanded(false)}
className="p-3 rounded-2xl bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-all">
<ChevronDown className="w-5 h-5" />
</button>
<p className="text-[10px] font-black text-[var(--accent-primary)] uppercase tracking-[0.3em]">En cours de lecture</p>
<button onClick={onClose}
className="p-3 rounded-2xl bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:text-accent-rose transition-all">
<X className="w-5 h-5" />
</button>
</div>
{/* Info + controls */}
<div className="flex flex-col gap-8 flex-1 w-full max-w-lg">
<div>
<p className="text-xs font-black text-[var(--accent-primary)] uppercase tracking-widest mb-2">{podcast?.title}</p>
<h2 className="text-3xl lg:text-4xl font-display font-black leading-tight mb-3">{episode.title}</h2>
<p className="text-sm text-[var(--text-secondary)] leading-relaxed line-clamp-3">{episode.description}</p>
<div className="flex flex-col lg:flex-row gap-12 items-center flex-1">
{/* Artwork */}
<div className="w-64 h-64 lg:w-80 lg:h-80 rounded-[2.5rem] overflow-hidden shadow-2xl border border-[var(--border-color)] shrink-0">
{(episode.imageUrl || podcast?.imageUrl)
? <img src={episode.imageUrl || podcast!.imageUrl} alt="" className="w-full h-full object-cover" />
: <div className="w-full h-full bg-gradient-to-br from-[var(--accent-primary)] to-[var(--accent-secondary)] flex items-center justify-center text-7xl">🎙️</div>
}
</div>
{/* Progress */}
<div>
<div className="relative h-1.5 group cursor-pointer mb-2">
<input type="range" min="0" max={duration || 0} value={currentTime}
onChange={handleProgressChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10" />
<div className="w-full h-full bg-[var(--text-primary)]/10 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-[var(--accent-primary)] to-[var(--accent-secondary)] rounded-full"
style={{ width: `${progress}%` }} />
{/* Info + controls */}
<div className="flex flex-col gap-8 flex-1 w-full max-w-lg">
<div>
<p className="text-xs font-black text-[var(--accent-primary)] uppercase tracking-widest mb-2">{podcast?.title}</p>
<h2 className="text-3xl lg:text-4xl font-display font-black leading-tight mb-3">{episode.title}</h2>
<p className="text-sm text-[var(--text-secondary)] leading-relaxed line-clamp-3">{episode.description}</p>
</div>
{/* Progress */}
<div>
<div className="relative h-1.5 group cursor-pointer mb-2">
<input type="range" min="0" max={duration || 0} value={currentTime}
onChange={handleProgressChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10" />
<div className="w-full h-full bg-[var(--text-primary)]/10 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-[var(--accent-primary)] to-[var(--accent-secondary)] rounded-full"
style={{ width: `${progress}%` }} />
</div>
</div>
<div className="flex justify-between text-xs text-[var(--text-secondary)] tabular-nums">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
<div className="flex justify-between text-xs text-[var(--text-secondary)] tabular-nums">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
{/* Controls */}
<div className="flex items-center justify-center gap-8">
<button onClick={() => seekRelative(-30)}
className="flex flex-col items-center gap-1 text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors group">
<RotateCcw className="w-6 h-6 group-hover:scale-110 transition-transform" />
<span className="text-[9px] font-black">-30s</span>
</button>
<button onClick={togglePlay}
className="w-20 h-20 rounded-full bg-[var(--text-primary)] text-[var(--bg-primary)] flex items-center justify-center shadow-glow-indigo hover:scale-105 active:scale-95 transition-transform">
{isPlaying ? <Pause className="w-8 h-8 fill-current" /> : <Play className="w-8 h-8 fill-current ml-1" />}
</button>
<button onClick={() => seekRelative(30)}
className="flex flex-col items-center gap-1 text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors group">
<RotateCw className="w-6 h-6 group-hover:scale-110 transition-transform" />
<span className="text-[9px] font-black">+30s</span>
</button>
</div>
</div>
{/* Controls */}
<div className="flex items-center justify-center gap-8">
<button onClick={() => handleSeek(-30)}
className="flex flex-col items-center gap-1 text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors group">
<RotateCcw className="w-6 h-6 group-hover:scale-110 transition-transform" />
<span className="text-[9px] font-black">-30s</span>
</button>
<button onClick={handlePlayPause}
className="w-20 h-20 rounded-full bg-[var(--text-primary)] text-[var(--bg-primary)] flex items-center justify-center shadow-glow-indigo hover:scale-105 active:scale-95 transition-transform">
{isPlaying ? <Pause className="w-8 h-8 fill-current" /> : <Play className="w-8 h-8 fill-current ml-1" />}
</button>
<button onClick={() => handleSeek(30)}
className="flex flex-col items-center gap-1 text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors group">
<RotateCw className="w-6 h-6 group-hover:scale-110 transition-transform" />
<span className="text-[9px] font-black">+30s</span>
</button>
</div>
{/* Volume */}
<div className="flex items-center gap-4">
<button onClick={changeSpeed} className="px-3 py-1.5 rounded-xl bg-[var(--bg-secondary)] text-xs font-black text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-all uppercase tracking-widest">
{playbackSpeed}x Speed
</button>
<button onClick={toggleMute} className="text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors shrink-0">
{isMuted ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
</button>
<input type="range" min="0" max="1" step="0.05" value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="flex-1 h-1 bg-[var(--text-primary)]/10 rounded-full appearance-none cursor-pointer accent-[var(--accent-primary)]" />
{/* Volume */}
<div className="flex items-center gap-4">
<button onClick={changeSpeed} className="px-3 py-1.5 rounded-xl bg-[var(--bg-secondary)] text-xs font-black text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-all uppercase tracking-widest">
{playbackSpeed}x Speed
</button>
<button onClick={toggleMute} className="text-[var(--text-secondary)] hover:text-[var(--accent-primary)] transition-colors shrink-0">
{isMuted ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
</button>
<input type="range" min="0" max="1" step="0.05" value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="flex-1 h-1 bg-[var(--text-primary)]/10 rounded-full appearance-none cursor-pointer accent-[var(--accent-primary)]" />
</div>
</div>
</div>
</div>
</div>
</div>
)}
</>
);
+8 -3
View File
@@ -21,8 +21,13 @@ export default function EpisodeCard({ episode, onPlay, onDetails }: EpisodeCardP
const durationMinutes = Math.floor(episode.duration / 60);
// Badges
const isCompleted = episode.progress?.isCompleted === true;
// Badges — consider completed if flagged OR if ≥90% listened
const progressPercent =
episode.progress && episode.duration > 0
? episode.progress.position / episode.duration
: 0;
const isCompleted =
episode.progress?.isCompleted === true || progressPercent >= 0.9;
const isNew =
!episode.progress &&
Date.now() - new Date(episode.pubDate).getTime() < 7 * 24 * 60 * 60 * 1000;
@@ -118,7 +123,7 @@ export default function EpisodeCard({ episode, onPlay, onDetails }: EpisodeCardP
</div>
{/* Local Progress Bar */}
{episode.progress && !episode.progress.isCompleted && (
{episode.progress && !isCompleted && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-white/5 rounded-b-[2rem] overflow-hidden">
<div
className="h-full bg-[var(--accent-primary)] shadow-glow-indigo transition-all duration-1000"
+143 -15
View File
@@ -1,44 +1,172 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import { Episode } from '../services/episodeService';
import { createContext, useContext, useState, useRef, useEffect, ReactNode } from 'react';
import { Episode, episodeService } from '../services/episodeService';
interface AudioContextType {
// State
currentEpisode: Episode | null;
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
playbackSpeed: number;
isMuted: boolean;
isResuming: boolean;
error: string | null;
// Actions
playEpisode: (episode: Episode) => void;
togglePlay: () => void;
closePlayer: () => void;
setIsPlaying: (isPlaying: boolean) => void;
togglePlay: () => void;
seek: (time: number) => void;
seekRelative: (delta: number) => void;
setVolume: (v: number) => void;
toggleMute: () => void;
changeSpeed: () => void;
setUserId: (id: string | null) => void;
setError: (err: string | null) => void;
// Legacy (kept for compatibility)
setIsPlaying: (playing: boolean) => void;
}
const AudioContext = createContext<AudioContextType | undefined>(undefined);
export function AudioProvider({ children }: { children: ReactNode }) {
const audioRef = useRef<HTMLAudioElement>(null);
const [currentEpisode, setCurrentEpisode] = useState<Episode | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolumeState] = useState(() => parseFloat(localStorage.getItem('podcastic-volume') || '1'));
const [playbackSpeed, setPlaybackSpeedState] = useState(() => parseFloat(localStorage.getItem('podcastic-speed') || '1'));
const [isMuted, setIsMuted] = useState(false);
const [isResuming, setIsResuming] = useState(false);
const [error, setError] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null);
// Refs for interval-based progress saving
const currentTimeRef = useRef(0);
const durationRef = useRef(0);
const episodeIdRef = useRef<string | null>(null);
const userIdRef = useRef<string | null>(null);
useEffect(() => { currentTimeRef.current = currentTime; }, [currentTime]);
useEffect(() => { durationRef.current = duration; }, [duration]);
useEffect(() => { episodeIdRef.current = currentEpisode?._id || null; }, [currentEpisode]);
useEffect(() => { userIdRef.current = userId; }, [userId]);
// Apply volume / speed to the audio element
useEffect(() => {
if (audioRef.current) {
audioRef.current.volume = isMuted ? 0 : volume;
audioRef.current.playbackRate = playbackSpeed;
}
}, [volume, isMuted, playbackSpeed, currentEpisode]);
// Persist volume / speed to localStorage
useEffect(() => {
localStorage.setItem('podcastic-volume', volume.toString());
localStorage.setItem('podcastic-speed', playbackSpeed.toString());
}, [volume, playbackSpeed]);
// Periodic + unmount progress saving. Considered completed at >= 90%.
useEffect(() => {
if (!currentEpisode || !userId) return;
const saveNow = () => {
if (!episodeIdRef.current || !userIdRef.current) return;
const pos = Math.floor(currentTimeRef.current);
const d = durationRef.current;
const completed = d > 0 && (pos / d) >= 0.9;
episodeService.saveProgress(episodeIdRef.current, pos, completed).catch(() => {});
};
const interval = setInterval(() => {
if (isPlaying) saveNow();
}, 20000);
return () => {
clearInterval(interval);
saveNow();
};
}, [currentEpisode?._id, userId, isPlaying]);
// Resume at previous position when episode loads
useEffect(() => {
if (!currentEpisode || !userId) return;
setIsResuming(true);
episodeService.getProgress(currentEpisode._id).then(r => {
if (r.progress?.position && audioRef.current) {
audioRef.current.currentTime = r.progress.position;
setCurrentTime(r.progress.position);
}
setIsResuming(false);
}).catch(() => setIsResuming(false));
}, [currentEpisode?._id, userId]);
// Actions
const playEpisode = (episode: Episode) => {
setCurrentEpisode(episode);
setIsPlaying(true);
};
const togglePlay = () => {
setIsPlaying(prev => !prev);
};
const closePlayer = () => {
if (audioRef.current) audioRef.current.pause();
setCurrentEpisode(null);
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
};
const togglePlay = () => {
if (!audioRef.current) return;
if (audioRef.current.paused) audioRef.current.play();
else audioRef.current.pause();
};
const seek = (time: number) => {
if (!audioRef.current) return;
audioRef.current.currentTime = time;
setCurrentTime(time);
};
const seekRelative = (delta: number) => {
if (!audioRef.current) return;
const t = Math.max(0, Math.min(duration, audioRef.current.currentTime + delta));
seek(t);
};
const setVolume = (v: number) => {
setVolumeState(v);
setIsMuted(v === 0);
};
const toggleMute = () => setIsMuted(m => !m);
const changeSpeed = () => {
const speeds = [1, 1.25, 1.5, 2, 0.75];
setPlaybackSpeedState(prev => speeds[(speeds.indexOf(prev) + 1) % speeds.length]);
};
return (
<AudioContext.Provider value={{
currentEpisode,
isPlaying,
playEpisode,
togglePlay,
closePlayer,
setIsPlaying
<AudioContext.Provider value={{
currentEpisode, isPlaying, currentTime, duration, volume, playbackSpeed, isMuted, isResuming, error,
playEpisode, closePlayer, togglePlay, seek, seekRelative, setVolume, toggleMute, changeSpeed,
setUserId, setError, setIsPlaying,
}}>
{/* Persistent audio element — lives at provider level so it survives page navigation */}
{currentEpisode && (
<audio
ref={audioRef}
src={currentEpisode.audioUrl}
onTimeUpdate={() => audioRef.current && setCurrentTime(audioRef.current.currentTime)}
onLoadedMetadata={() => audioRef.current && setDuration(audioRef.current.duration)}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
onError={() => setError("Impossible de charger ce fichier audio. Le lien est peut-être expiré ou protégé.")}
autoPlay
/>
)}
{children}
</AudioContext.Provider>
);
+8 -3
View File
@@ -12,7 +12,7 @@ import AudioPlayer from '../components/AudioPlayer';
import { Sparkles, Play, Clock, TrendingUp, Loader, RefreshCcw } from 'lucide-react';
export default function Dashboard() {
const { playEpisode, currentEpisode, closePlayer } = useAudio();
const { playEpisode, currentEpisode, closePlayer, setUserId } = useAudio();
const [user, setUser] = useState<any>(null);
const [detailsEpisode, setDetailsEpisode] = useState<Episode | null>(null);
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
@@ -46,6 +46,7 @@ export default function Dashboard() {
try {
const response = await authService.getMe();
setUser(response.user);
setUserId(response.user._id);
} catch (error) {
console.error('Failed to load user:', error);
} finally {
@@ -102,8 +103,12 @@ export default function Dashboard() {
}
};
// Extract episodes in progress (using all loaded episodes)
const episodesInProgress = episodes?.filter(e => e.progress && !e.progress.isCompleted) || [];
// Episodes in progress: has progress, not completed, not ≥90%
const episodesInProgress = episodes?.filter(e => {
if (!e.progress || e.progress.isCompleted) return false;
if (e.duration > 0 && e.progress.position / e.duration >= 0.9) return false;
return e.progress.position > 0;
}) || [];
if (loading) {
return (