mirror of
https://github.com/R0m1k3/podcastic.git
synced 2026-10-21 22:18:03 +02:00
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:
1 parent
842ead6518
commit
5a9a0904a1
4 files changed
+276
-245
No files matched your search
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user