From 17f2496dda878acf18f3f33d6c92b9b2eb4dbd73 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Thu, 16 Apr 2026 15:34:09 +0200 Subject: [PATCH] feat: implement global audio player with playback controls, progress tracking, and persistent settings --- backend/src/services/podcastIndexService.ts | 8 +- frontend/src/App.tsx | 121 +++++++++++++------- frontend/src/components/AudioPlayer.tsx | 16 ++- frontend/src/constants.ts | 23 ++++ frontend/src/context/AudioContext.tsx | 53 +++++++++ frontend/src/pages/AddPodcast.tsx | 58 +++++++--- frontend/src/pages/Dashboard.tsx | 56 ++++++--- frontend/src/pages/Trending.tsx | 14 +-- frontend/src/styles/globals.css | 34 +++--- 9 files changed, 274 insertions(+), 109 deletions(-) create mode 100644 frontend/src/constants.ts create mode 100644 frontend/src/context/AudioContext.tsx diff --git a/backend/src/services/podcastIndexService.ts b/backend/src/services/podcastIndexService.ts index ef56222..3b5f2db 100644 --- a/backend/src/services/podcastIndexService.ts +++ b/backend/src/services/podcastIndexService.ts @@ -69,8 +69,9 @@ export const podcastIndexService = { term, media: 'podcast', entity: 'podcast', - country: 'FR', // Prioritize French store - limit: Math.max(limit, 50), // Get more results to find the best match + country: 'FR', + lang: 'fr_fr', // Force French localized search + limit: 100, // Fetch more candidates to improve quality }, timeout: 10000, } @@ -78,7 +79,8 @@ export const podcastIndexService = { return response.data.results .map(mapItunesResult) - .filter((r): r is SearchResult => r !== null); + .filter((r): r is SearchResult => r !== null) + .slice(0, limit); // Respect requested limit after fetching candidates }, getTrendingPodcasts: async (limit: number = 20, genreId?: string): Promise => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 94fbb95..12669b2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,13 +1,16 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useState, useEffect } from 'react'; import './styles/globals.css'; import { authService } from './services/authService'; +import { AudioProvider, useAudio } from './context/AudioContext'; import Login from './pages/Login'; import Dashboard from './pages/Dashboard'; import Trending from './pages/Trending'; import AddPodcast from './pages/AddPodcast'; import Library from './pages/Library'; import Navigation from './components/Navigation'; +import AudioPlayer from './components/AudioPlayer'; const queryClient = new QueryClient({ defaultOptions: { @@ -18,6 +21,78 @@ const queryClient = new QueryClient({ }, }); +// App Content Wrapper to access AudioContext +function AppContent() { + const { currentEpisode, closePlayer } = useAudio(); + const [user, setUser] = useState(null); + + // Load user for the player + useEffect(() => { + const loadUser = async () => { + try { + const { accessToken } = authService.getTokens(); + if (accessToken) { + const response = await authService.getMe(); + setUser(response.user); + } + } catch (error) { + console.error('Failed to load user for global player'); + } + }; + loadUser(); + }, [currentEpisode]); // Reload if needed or on mount + + return ( + <> + + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + } /> + + + {currentEpisode && ( + + )} + + ); +} + // Protected Route Component function ProtectedRoute({ children }: { children: React.ReactNode }) { const { accessToken } = authService.getTokens(); @@ -29,7 +104,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { return (
-
+
{children}
@@ -41,45 +116,11 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { export default function App() { return ( - - - } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - } /> - } /> - - + + + + + ); } diff --git a/frontend/src/components/AudioPlayer.tsx b/frontend/src/components/AudioPlayer.tsx index 1a1452d..4a0ac94 100644 --- a/frontend/src/components/AudioPlayer.tsx +++ b/frontend/src/components/AudioPlayer.tsx @@ -11,7 +11,9 @@ import { X, RotateCcw, RotateCw, + AlertCircle } from 'lucide-react'; +import AlertModal from './AlertModal'; interface AudioPlayerProps { episode: Episode | null; @@ -28,6 +30,7 @@ export default function AudioPlayer({ episode, onClose, userId }: AudioPlayerPro const [playbackSpeed, setPlaybackSpeed] = useState(() => parseFloat(localStorage.getItem('podcastic-speed') || '1')); const [isMuted, setIsMuted] = useState(false); const [isExpanded, setIsExpanded] = useState(false); + const [error, setError] = useState(null); const podcast = typeof episode?.podcastId === 'object' ? episode.podcastId : null; @@ -130,10 +133,21 @@ export default function AudioPlayer({ episode, onClose, userId }: AudioPlayerPro onTimeUpdate={() => audioRef.current && setCurrentTime(audioRef.current.currentTime)} onLoadedMetadata={() => audioRef.current && setDuration(audioRef.current.duration)} onEnded={() => setIsPlaying(false)} - crossOrigin="anonymous" + onError={() => setError("Impossible de charger ce fichier audio. Le lien est peut-être expiré ou protégé.")} autoPlay /> + { + setError(null); + onClose(); + }} + /> + {/* ── COMPACT BAR ── */}
diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts new file mode 100644 index 0000000..5a678cb --- /dev/null +++ b/frontend/src/constants.ts @@ -0,0 +1,23 @@ +export interface Genre { + id: string; + label: string; +} + +export const GENRES: Genre[] = [ + { id: '', label: 'Tout' }, + { id: '1318', label: 'Technologie' }, + { id: '1303', label: 'Humour' }, + { id: '1321', label: 'Business' }, + { id: '1311', label: 'News' }, + { id: '1324', label: 'Société' }, + { id: '1316', label: 'Sports' }, + { id: '1310', label: 'Musique' }, + { id: '1301', label: 'Arts' }, + { id: '1315', label: 'Science' }, + { id: '1304', label: 'Éducation' }, + { id: '1307', label: 'Santé' }, + { id: '1305', label: 'Famille' }, + { id: '1340', label: 'Fiction' }, + { id: '1325', label: 'Histoire' }, + { id: '1333', label: 'True Crime' }, +]; diff --git a/frontend/src/context/AudioContext.tsx b/frontend/src/context/AudioContext.tsx new file mode 100644 index 0000000..a24cccf --- /dev/null +++ b/frontend/src/context/AudioContext.tsx @@ -0,0 +1,53 @@ +import React, { createContext, useContext, useState, ReactNode } from 'react'; +import { Episode } from '../services/episodeService'; + +interface AudioContextType { + currentEpisode: Episode | null; + isPlaying: boolean; + playEpisode: (episode: Episode) => void; + togglePlay: () => void; + closePlayer: () => void; + setIsPlaying: (isPlaying: boolean) => void; +} + +const AudioContext = createContext(undefined); + +export function AudioProvider({ children }: { children: ReactNode }) { + const [currentEpisode, setCurrentEpisode] = useState(null); + const [isPlaying, setIsPlaying] = useState(false); + + const playEpisode = (episode: Episode) => { + setCurrentEpisode(episode); + setIsPlaying(true); + }; + + const togglePlay = () => { + setIsPlaying(prev => !prev); + }; + + const closePlayer = () => { + setCurrentEpisode(null); + setIsPlaying(false); + }; + + return ( + + {children} + + ); +} + +export function useAudio() { + const context = useContext(AudioContext); + if (context === undefined) { + throw new Error('useAudio must be used within an AudioProvider'); + } + return context; +} diff --git a/frontend/src/pages/AddPodcast.tsx b/frontend/src/pages/AddPodcast.tsx index 880365a..a4c9a08 100644 --- a/frontend/src/pages/AddPodcast.tsx +++ b/frontend/src/pages/AddPodcast.tsx @@ -6,14 +6,16 @@ import { episodeService, Episode } from '../services/episodeService'; import Header from '../components/Header'; import EpisodeCard from '../components/EpisodeCard'; import EpisodeDetails from '../components/EpisodeDetails'; -import AudioPlayer from '../components/AudioPlayer'; +import { useAudio } from '../context/AudioContext'; import AlertModal, { AlertType } from '../components/AlertModal'; import { Search, Plus, Loader, Rss, Check, Globe, Sparkles, PlusCircle, LayoutGrid, ListMusic } from 'lucide-react'; import { authService } from '../services/authService'; import SuccessModal from '../components/SuccessModal'; +import { GENRES } from '../constants'; export default function AddPodcast() { const queryClient = useQueryClient(); + const { playEpisode } = useAudio(); const [user, setUser] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [searchType, setSearchType] = useState<'podcasts' | 'episodes'>('podcasts'); @@ -35,9 +37,9 @@ export default function AddPodcast() { }); const [rssSuccess, setRssSuccess] = useState(null); - const [selectedEpisode, setSelectedEpisode] = useState(null); const [detailsEpisode, setDetailsEpisode] = useState(null); const [isDetailsOpen, setIsDetailsOpen] = useState(false); + const [selectedGenre, setSelectedGenre] = useState(GENRES[1].id); // Tech by default or first genre // Modal state const [showSuccessModal, setShowSuccessModal] = useState(false); @@ -76,6 +78,14 @@ export default function AddPodcast() { staleTime: 2 * 60 * 1000, }); + // Trending discovery for the "Add" page when no search is active + const { data: trendingResults, isLoading: isTrendingLoading } = useQuery({ + queryKey: ['discover', 'trending', selectedGenre], + queryFn: () => discoveryService.getTrendingPodcasts(18, selectedGenre), + enabled: searchQuery.length === 0 && searchType === 'podcasts', + staleTime: 30 * 60 * 1000, + }); + const { data: subsData } = useQuery({ queryKey: ['podcasts', 'subscriptions'], queryFn: () => podcastService.getUserSubscriptions(), @@ -215,17 +225,39 @@ export default function AddPodcast() { />
+ + {/* Category Discovery Pills */} + {searchType === 'podcasts' && ( +
+ {GENRES.map(genre => ( + + ))} +
+ )}
{/* Dynamic Results Grid */} - {(isSearching || isSearchingEpisodes) ? ( + {(isSearching || isSearchingEpisodes || isTrendingLoading) ? (
- {[1,2,3].map(i =>
)} + {[1,2,3,4,5,6].map(i =>
)}
- ) : searchType === 'podcasts' && searchResults?.podcasts && searchResults.podcasts.length > 0 ? ( + ) : searchType === 'podcasts' && (searchQuery.length > 0 ? searchResults?.podcasts : trendingResults?.podcasts) ? (
- {searchResults.podcasts.map((podcast) => ( -
+ {(searchQuery.length > 0 ? searchResults?.podcasts : trendingResults?.podcasts).map((podcast: any) => ( +
{podcast.imageUrl ? ( {podcast.title} @@ -260,7 +292,7 @@ export default function AddPodcast() { ))} @@ -284,7 +316,7 @@ export default function AddPodcast() { episode={detailsEpisode} isOpen={isDetailsOpen} onClose={() => setIsDetailsOpen(false)} - onPlay={setSelectedEpisode} + onPlay={playEpisode} /> setAlertConfig(prev => ({ ...prev, isOpen: false }))} /> - {selectedEpisode && ( - setSelectedEpisode(null)} - userId={user?.id} - /> - )} - setShowSuccessModal(false)} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 9fe55a4..a8d9969 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -6,12 +6,13 @@ import { authService } from '../services/authService'; import Header from '../components/Header'; import EpisodeCard from '../components/EpisodeCard'; import EpisodeDetails from '../components/EpisodeDetails'; -import AudioPlayer from '../components/AudioPlayer'; +import { useAudio } from '../context/AudioContext'; +import AlertModal, { AlertType } from '../components/AlertModal'; import { Sparkles, Play, Clock, TrendingUp, Loader, RefreshCcw } from 'lucide-react'; export default function Dashboard() { + const { playEpisode } = useAudio(); const [user, setUser] = useState(null); - const [selectedEpisode, setSelectedEpisode] = useState(null); const [detailsEpisode, setDetailsEpisode] = useState(null); const [isDetailsOpen, setIsDetailsOpen] = useState(false); const [loading, setLoading] = useState(true); @@ -20,6 +21,19 @@ export default function Dashboard() { const [hasMore, setHasMore] = useState(true); const LIMIT = 32; + // Alert states + const [alertConfig, setAlertConfig] = useState<{ + isOpen: boolean; + title: string; + message: string; + type: AlertType; + }>({ + isOpen: false, + title: '', + message: '', + type: 'error' + }); + const handleOpenDetails = (episode: Episode) => { setDetailsEpisode(episode); setIsDetailsOpen(true); @@ -78,7 +92,12 @@ export default function Dashboard() { authService.clearTokens(); window.location.href = '/login'; } catch (error) { - console.error('Logout failed:', error); + setAlertConfig({ + isOpen: true, + title: "Erreur de déconnexion", + message: "Une erreur s'est produite lors de la déconnexion. Veuillez rafraîchir la page.", + type: 'error' + }); } }; @@ -111,10 +130,10 @@ export default function Dashboard() { Podcastic Premium
-

+

Découvrez les nouveautés de votre univers audio.

-

+

Vous avez des nouveaux épisodes qui n'attendent que vous. Prêt pour une immersion ?

@@ -153,7 +172,7 @@ export default function Dashboard() { ))} @@ -185,7 +204,7 @@ export default function Dashboard() { ))} @@ -205,10 +224,10 @@ export default function Dashboard() { )} ) : ( -
+
🧘
-

Silence radio...

-

+

Silence radio...

+

Vous n'avez pas encore d'épisodes ici. Abonnez-vous à vos podcasts favoris pour commencer votre collection.

Explorer les podcasts @@ -221,17 +240,16 @@ export default function Dashboard() { episode={detailsEpisode} isOpen={isDetailsOpen} onClose={() => setIsDetailsOpen(false)} - onPlay={setSelectedEpisode} + onPlay={playEpisode} /> - {/* Audio Player Management */} - {selectedEpisode && ( - setSelectedEpisode(null)} - userId={user?._id} - /> - )} + setAlertConfig(prev => ({ ...prev, isOpen: false }))} + />
); } diff --git a/frontend/src/pages/Trending.tsx b/frontend/src/pages/Trending.tsx index f5dc5d6..2d38078 100644 --- a/frontend/src/pages/Trending.tsx +++ b/frontend/src/pages/Trending.tsx @@ -7,6 +7,7 @@ import { Loader, Plus, Flame, Check, Sparkles, TrendingUp } from 'lucide-react'; import { authService } from '../services/authService'; import SuccessModal from '../components/SuccessModal'; import AlertModal, { AlertType } from '../components/AlertModal'; +import { GENRES } from '../constants'; export default function Trending() { const queryClient = useQueryClient(); @@ -31,18 +32,7 @@ export default function Trending() { type: 'error' }); - const GENRES = [ - { id: '', label: 'Tout' }, - { id: '1318', label: 'Technologie' }, - { id: '1303', label: 'Humour' }, - { id: '1321', label: 'Business' }, - { id: '1311', label: 'News' }, - { id: '1324', label: 'Société' }, - { id: '1316', label: 'Sports' }, - { id: '1310', label: 'Musique' }, - { id: '1301', label: 'Arts' }, - { id: '1315', label: 'Science' }, - ]; + useEffect(() => { const loadUser = async () => { diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 9451020..2de3a93 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -16,25 +16,25 @@ --glass-blur: blur(24px) saturate(180%); --shadow-premium: 0 25px 60px rgba(0, 0, 0, 0.6), 0 0 1px rgba(255, 255, 255, 0.1); --input-bg: rgba(255, 255, 255, 0.03); - --radius-card: 1.5rem; /* 24px */ - --radius-panel: 2rem; /* 32px */ + --radius-card: 1rem; /* 16px - Sharper, Industrial */ + --radius-panel: 1.25rem; /* 20px */ } /* ── Premium Gold (Light Theme) ── */ :root.light-theme, .light-theme { - --bg-primary: #FDFCFB; /* Off-white */ - --bg-secondary: rgba(212, 175, 55, 0.05); /* Very light gold tint */ - --text-primary: #0a0a0a; /* True black */ - --text-secondary: #4b5563; /* Slate 600 */ - --accent-primary: #C5A028; /* Premium Gold */ - --accent-secondary: #856404; /* Darker Gold */ - --accent-glow: rgba(197, 160, 40, 0.2); - --border-color: rgba(197, 160, 40, 0.15); - --glass-blur: blur(20px) saturate(120%); - --shadow-premium: 0 20px 50px rgba(197, 160, 40, 0.15), 0 10px 30px rgba(0, 0, 0, 0.06); - --radius-card: 1.5rem; - --radius-panel: 2rem; - --input-bg: #ffffff; + --bg-primary: #ffffff; /* Pure white for maximum clarity */ + --bg-secondary: #f8fafc; /* Slate 50 tint */ + --text-primary: #0f172a; /* Slate 900 */ + --text-secondary: #64748b; /* Slate 500 */ + --accent-primary: #6366f1; /* Consistent Indigo across themes for brand */ + --accent-secondary: #06b6d4; + --accent-glow: rgba(99, 102, 241, 0.1); + --border-color: #e2e8f0; /* Soft slate border */ + --glass-blur: blur(12px); + --shadow-premium: 0 10px 40px rgba(0, 0, 0, 0.04), 0 0 1px rgba(0, 0, 0, 0.05); + --radius-card: 1rem; + --radius-panel: 1.25rem; + --input-bg: #f1f5f9; } } @@ -68,8 +68,8 @@ body.light-theme::before { inset: 0; z-index: -1; background-image: - radial-gradient(ellipse 80% 40% at 10% 10%, rgba(197, 160, 40, 0.05) 0%, transparent 60%), - radial-gradient(ellipse 60% 50% at 90% 90%, rgba(255, 255, 255, 0.8) 0%, transparent 60%); + radial-gradient(ellipse 80% 40% at 10% 10%, rgba(99, 102, 241, 0.03) 0%, transparent 60%), + radial-gradient(ellipse 60% 50% at 90% 90%, rgba(255, 255, 255, 1) 0%, transparent 60%); pointer-events: none; }