feat: integrate Bunny Stream course videos
All checks were successful
CI / Validate frontend and API (push) Successful in 1m50s
CI / Build and publish Docker images (push) Successful in 18s

This commit is contained in:
Cauê Faleiros
2026-09-02 10:09:36 -03:00
parent 3d4c72e85c
commit 2c137529bc
15 changed files with 607 additions and 40 deletions

View File

@@ -22,6 +22,7 @@ import { useAuth } from '../context/AuthContext';
import { getComments, getLessonProgress, saveComment, saveLessonCompletion, saveLessonProgress, incrementViews } from '../services/db';
import { LoginModal } from './LoginModal';
import { MaterialCard } from './MaterialCard';
import { courseApi, LessonPlayback } from '../services/api';
interface CoursePlayerModalProps {
course: Course | null;
@@ -38,6 +39,8 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const [activeTab, setActiveTab] = useState<'playlist' | 'materials' | 'discussion'>('playlist');
const [copiedLink, setCopiedLink] = useState(false);
const [playback, setPlayback] = useState<LessonPlayback | null>(null);
const [playbackError, setPlaybackError] = useState('');
const videoRef = useRef<HTMLVideoElement>(null);
const lastProgressSave = useRef<Record<string, number>>({});
@@ -70,6 +73,27 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
}
}, [course, user]);
// The API verifies the session before returning a time-limited Bunny embed
// URL. A video provider key is never sent to the browser.
useEffect(() => {
if (!course || !user || !currentLesson) {
setPlayback(null);
setPlaybackError('');
return;
}
let cancelled = false;
setPlayback(null);
setPlaybackError('');
courseApi.playback(course.id, currentLesson.id)
.then((response) => {
if (!cancelled) setPlayback(response.data);
})
.catch((error) => {
if (!cancelled) setPlaybackError(error instanceof Error ? error.message : 'Não foi possível carregar este vídeo.');
});
return () => { cancelled = true; };
}, [course, user, currentLesson]);
// Persist completed lessons and recalculate progress
const updateCompletedLessons = (newCompleted: string[]) => {
setCompletedLessonIds(newCompleted);
@@ -239,19 +263,38 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
{/* Video Player Section */}
<div className="w-full aspect-video bg-black rounded-[8px] overflow-hidden relative shadow-xl border border-white/10 group">
{canWatch ? (
<video
key={currentLesson?.id}
ref={videoRef}
src={currentLesson?.videoUrl}
className="w-full h-full object-contain rounded-[8px]"
controls
autoPlay
onTimeUpdate={handleTimeUpdate}
onEnded={handleVideoEnded}
onLoadedMetadata={restoreWatchPosition}
>
Seu navegador não suporta a tag de vídeo.
</video>
playback ? (
playback.kind === 'embed' && playback.embedUrl ? (
<iframe
key={currentLesson?.id}
src={playback.embedUrl}
title={currentLesson?.title || 'Vídeo da aula'}
className="w-full h-full border-0 rounded-[8px]"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; fullscreen"
allowFullScreen
/>
) : playback.source ? (
<video
key={currentLesson?.id}
ref={videoRef}
src={playback.source}
className="w-full h-full object-contain rounded-[8px]"
controls
autoPlay
onTimeUpdate={handleTimeUpdate}
onEnded={handleVideoEnded}
onLoadedMetadata={restoreWatchPosition}
>
Seu navegador não suporta a tag de vídeo.
</video>
) : (
<div className="absolute inset-0 flex items-center justify-center text-sm text-gray-400">Vídeo indisponível.</div>
)
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-center p-6 text-sm text-gray-400">
{playbackError ? <span className="text-amber-300">{playbackError}</span> : <span>Carregando vídeo seguro...</span>}
</div>
)
) : (
// Locked State
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-950/90 backdrop-blur-md z-10 text-center p-6 rounded-[8px]">