Files

202 lines
7.7 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { Stage } from '@/components/stage';
import { MediaStageProvider } from '@/lib/contexts/media-stage-context';
import { ThemeProvider } from '@/lib/hooks/use-theme';
import {
applyClassroomStageAndScenes,
type ClassroomPayload,
} from '@/lib/classroom/load-classroom';
import { applyGeneratedAgentsToRegistry } from '@/lib/orchestration/registry/store';
import { useSettingsStore } from '@/lib/store/settings';
import { useStageStore } from '@/lib/store';
import {
installMakeloreRuntimeFetchBridge,
type MakeloreCourseRuntimeContext,
} from '@/lib/makelore-runtime/browser-bridge';
import {
MAKELORE_PLAYBACK_PROGRESS_EVENT,
type MakelorePlaybackProgress,
} from '@/lib/makelore-runtime/progress-events';
import classroomFixture from '@/fixtures/makelore/python-basics-classroom.json';
const FIXTURE = classroomFixture as unknown as ClassroomPayload;
type CourseLoadMessage = {
type: 'makelore:course:load';
courseId: string;
/** Aggregate hash (canonical). */
courseContentHash?: string;
/** Aggregate hash compatibility alias. */
contentHash?: string;
moduleId?: string | null;
moduleContentHash?: string;
classroom: ClassroomPayload;
progress?: { sceneOrder?: number; actionIndex?: number; positionMs?: number; completed?: boolean };
};
function isClassroomPayload(value: unknown): value is ClassroomPayload {
if (!value || typeof value !== 'object') return false;
const record = value as Record<string, unknown>;
return Boolean(record.stage)
&& typeof record.stage === 'object'
&& Array.isArray(record.scenes);
}
/**
* Playback-only integration fixture for the Makelore desktop player.
*
* This route deliberately renders the production Stage instead of a reduced
* mock, so slides, interactive HTML, quizzes, the whiteboard, playback
* actions, and the assistant chrome are exercised together. The final desktop
* bridge supplies the same payload from a verified local course package.
*/
export default function MakelorePlayerPage() {
const [classroom, setClassroom] = useState<ClassroomPayload | null>(null);
const [error, setError] = useState<string | null>(null);
const [initialSceneOrder, setInitialSceneOrder] = useState<number | null>(null);
useEffect(() => {
const embedded = new URLSearchParams(window.location.search).get('embedded') === '1';
if (!embedded) {
setClassroom(FIXTURE);
return;
}
window.__MAKELORE_OFFLINE_PLAYER__ = true;
const uninstallRuntimeBridge = installMakeloreRuntimeFetchBridge({
getAnchor: () => {
const state = useStageStore.getState();
const scene = state.scenes.find((candidate) => candidate.id === state.currentSceneId);
if (!scene) return null;
return { sceneId: scene.id, sceneOrder: scene.order, sceneTitle: scene.title };
},
});
const onPlaybackProgress = (event: Event) => {
const detail = (event as CustomEvent<MakelorePlaybackProgress>).detail;
if (!detail || typeof detail.sceneId !== 'string') return;
window.parent.postMessage({
type: 'makelore:progress',
sceneOrder: detail.sceneOrder,
actionIndex: detail.actionIndex,
positionMs: null,
completed: detail.completed,
}, '*');
};
window.addEventListener(MAKELORE_PLAYBACK_PROGRESS_EVENT, onPlaybackProgress);
let timeout = 0;
const onMessage = (event: MessageEvent<unknown>) => {
if (event.source !== window.parent || !event.data || typeof event.data !== 'object') return;
const message = event.data as Partial<CourseLoadMessage>;
const courseContentHash = typeof message.courseContentHash === 'string'
? message.courseContentHash
: message.contentHash;
if (message.type !== 'makelore:course:load'
|| typeof message.courseId !== 'string'
|| typeof courseContentHash !== 'string'
|| !isClassroomPayload(message.classroom)) return;
const moduleContentHash = typeof message.moduleContentHash === 'string'
? message.moduleContentHash
: courseContentHash;
window.__MAKELORE_COURSE_CONTEXT__ = {
courseId: message.courseId,
courseContentHash,
contentHash: courseContentHash,
moduleId: typeof message.moduleId === 'string' ? message.moduleId : null,
moduleContentHash,
};
window.clearTimeout(timeout);
setError(null);
setInitialSceneOrder(typeof message.progress?.sceneOrder === 'number'
? message.progress.sceneOrder
: null);
window.__MAKELORE_INITIAL_PROGRESS__ = {
sceneOrder: typeof message.progress?.sceneOrder === 'number'
? message.progress.sceneOrder
: undefined,
actionIndex: typeof message.progress?.actionIndex === 'number'
? message.progress.actionIndex
: null,
completed: message.progress?.completed === true,
};
setClassroom(message.classroom);
};
window.addEventListener('message', onMessage);
window.parent.postMessage({ type: 'makelore:player:ready' }, '*');
timeout = window.setTimeout(() => setError('没有收到本地课件数据'), 15_000);
return () => {
window.clearTimeout(timeout);
window.removeEventListener('message', onMessage);
window.removeEventListener(MAKELORE_PLAYBACK_PROGRESS_EVENT, onPlaybackProgress);
uninstallRuntimeBridge();
delete window.__MAKELORE_COURSE_CONTEXT__;
delete window.__MAKELORE_INITIAL_PROGRESS__;
delete window.__MAKELORE_OFFLINE_PLAYER__;
};
}, []);
useEffect(() => {
if (!classroom) return;
const { stage, scenes } = classroom;
applyClassroomStageAndScenes(stage, scenes, { persist: false });
const generatedAgentIds = applyGeneratedAgentsToRegistry(
stage.id,
stage.generatedAgentConfigs ?? [],
);
const settings = useSettingsStore.getState();
settings.setAgentMode('auto');
settings.setSelectedAgentIds(generatedAgentIds);
settings.setAgentSelectionIsUserSet(false);
const resumedScene = initialSceneOrder === null
? undefined
: scenes.find((scene) => scene.order === initialSceneOrder);
useStageStore.setState({
generationComplete: true,
mode: 'playback',
...(resumedScene ? { currentSceneId: resumedScene.id } : {}),
});
window.parent.postMessage({ type: 'makelore:player:loaded', stageId: stage.id }, '*');
let lastSceneId = useStageStore.getState().currentSceneId;
return useStageStore.subscribe((state) => {
if (!state.currentSceneId || state.currentSceneId === lastSceneId) return;
lastSceneId = state.currentSceneId;
const scene = state.scenes.find((candidate) => candidate.id === state.currentSceneId);
if (scene) window.parent.postMessage({
type: 'makelore:progress',
sceneOrder: scene.order,
actionIndex: 0,
positionMs: null,
completed: false,
}, '*');
});
}, [classroom, initialSceneOrder]);
return (
<ThemeProvider>
<MediaStageProvider value={classroom?.stage.id ?? 'makelore-loading'}>
<main className="flex h-screen overflow-hidden bg-background">
{classroom ? (
<Stage />
) : (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{error ?? '正在打开课件…'}
</div>
)}
</main>
</MediaStageProvider>
</ThemeProvider>
);
}
declare global {
interface Window {
__MAKELORE_OFFLINE_PLAYER__?: boolean;
__MAKELORE_COURSE_CONTEXT__?: MakeloreCourseRuntimeContext;
__MAKELORE_INITIAL_PROGRESS__?: {
sceneOrder?: number;
actionIndex?: number | null;
completed?: boolean;
};
}
}