202 lines
7.7 KiB
TypeScript
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;
|
|
};
|
|
}
|
|
}
|