feat: complete platform operations roadmap
All checks were successful
CI / Validate frontend and API (push) Successful in 1m58s
CI / Build and publish Docker images (push) Successful in 22s

This commit is contained in:
Cauê Faleiros
2026-09-04 10:07:07 -03:00
parent 2c137529bc
commit 9b6fea4b81
19 changed files with 589 additions and 66 deletions

View File

@@ -24,6 +24,32 @@ import { LoginModal } from './LoginModal';
import { MaterialCard } from './MaterialCard';
import { courseApi, LessonPlayback } from '../services/api';
declare global {
interface Window {
playerjs?: {
Player: new (element: HTMLIFrameElement) => {
on: (event: string, listener: (data?: { seconds?: number; duration?: number }) => void) => void;
};
};
}
}
let bunnyPlayerJsPromise: Promise<void> | null = null;
const loadBunnyPlayerJs = () => {
if (window.playerjs) return Promise.resolve();
if (bunnyPlayerJsPromise) return bunnyPlayerJsPromise;
bunnyPlayerJsPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://assets.mediadelivery.net/playerjs/player-0.1.0.min.js';
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Bunny player events could not be loaded'));
document.head.appendChild(script);
});
return bunnyPlayerJsPromise;
};
interface CoursePlayerModalProps {
course: Course | null;
onClose: () => void;
@@ -43,6 +69,7 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
const [playbackError, setPlaybackError] = useState('');
const videoRef = useRef<HTMLVideoElement>(null);
const bunnyFrameRef = useRef<HTMLIFrameElement>(null);
const lastProgressSave = useRef<Record<string, number>>({});
// Load course data and this learner's saved state from PostgreSQL.
@@ -162,6 +189,31 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
}
};
// Bunny's iframe does not expose an HTMLVideoElement. Player.js receives
// Bunny's trusted playback events and sends the same progress updates used
// for external video providers.
useEffect(() => {
if (!playback || playback.kind !== 'embed' || !currentLesson || !user) return;
let disposed = false;
const lessonId = currentLesson.id;
void loadBunnyPlayerJs().then(() => {
if (disposed || !bunnyFrameRef.current || !window.playerjs) return;
const player = new window.playerjs.Player(bunnyFrameRef.current);
player.on('timeupdate', (data) => {
const watchedSeconds = Math.floor(data?.seconds || 0);
if (disposed || watchedSeconds <= 0 || watchedSeconds - (lastProgressSave.current[lessonId] || 0) < 30) return;
lastProgressSave.current[lessonId] = watchedSeconds;
setWatchedSecondsByLesson((current) => ({ ...current, [lessonId]: watchedSeconds }));
saveLessonProgress(lessonId, watchedSeconds).catch(() => undefined);
});
player.on('ended', (data) => {
if (disposed) return;
markLessonCompleted(lessonId, Math.floor(data?.duration || data?.seconds || 0));
});
}).catch(() => undefined);
return () => { disposed = true; };
}, [playback, currentLesson, user, completedLessonIds]);
const restoreWatchPosition = () => {
if (!currentLesson || !videoRef.current) return;
const watchedSeconds = watchedSecondsByLesson[currentLesson.id] || 0;
@@ -267,6 +319,7 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
playback.kind === 'embed' && playback.embedUrl ? (
<iframe
key={currentLesson?.id}
ref={bunnyFrameRef}
src={playback.embedUrl}
title={currentLesson?.title || 'Vídeo da aula'}
className="w-full h-full border-0 rounded-[8px]"