feat: 更新音频和引导对话,优化用户体验

- 在多个场景中添加音频资源,增强学习互动性。
- 更新引导对话文本,确保信息准确性和一致性。
- 调整组件结构,清理不必要的代码,提升可维护性。
- 引入结束窗口,鼓励用户深入了解历史背景。
This commit is contained in:
zhongyidian committed 2026-01-15 19:30:17 +08:00
1 parent d7ff7d3a45
commit 6e106032fd
141 files changed
+1375 -479

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
import { useCallback, useEffect, useState } from "react";
type AudioMode = "global" | "channel";
interface UseAudioManagerOptions {
mode?: AudioMode;
channelId?: string;
}
interface AudioState {
key: string | null;
currentSrc: string | null;
isPlaying: boolean;
}
// 全局音频池:每个 key(全局或某个 channel)对应一个 HTMLAudioElement
const audioPool = new Map<string, HTMLAudioElement>();
// 订阅者列表,用于在任意地方播放/暂停时同步状态
const listeners = new Set<(state: AudioState) => void>();
let globalState: AudioState = {
key: null,
currentSrc: null,
isPlaying: false,
};
function emit(state: AudioState) {
globalState = state;
listeners.forEach((listener) => listener(state));
}
function resolveKey(options?: UseAudioManagerOptions): string {
const mode = options?.mode ?? "global";
if (mode === "global") {
return "__GLOBAL__";
}
// mode === "channel"
return `channel:${options?.channelId ?? "__DEFAULT__"}`;
}
function getOrCreateAudio(key: string): HTMLAudioElement {
let audio = audioPool.get(key);
if (!audio) {
audio = new Audio();
audioPool.set(key, audio);
audio.addEventListener("ended", () => {
// 某个 key 下的音频自然播放结束时,仅更新该 key 对应的播放状态
if (globalState.key === key) {
emit({
key,
currentSrc: globalState.currentSrc,
isPlaying: false,
});
}
});
}
return audio;
}
/**
* 分组互斥音频管理:
* - mode = "global":全局单例,所有使用方互斥
* - mode = "channel" + channelId:同一 channel 内互斥,channel 之间互不影响
*/
export function useAudioManager(options?: UseAudioManagerOptions) {
const [state, setState] = useState<AudioState>(globalState);
// 将 options 展开为基础类型,避免在 useCallback 中依赖对象引用导致每次 render 都重建函数
const resolvedMode: AudioMode = options?.mode ?? "global";
const resolvedChannelId: string | undefined = options?.channelId;
useEffect(() => {
listeners.add(setState);
return () => {
listeners.delete(setState);
};
}, []);
const play = useCallback(
(src: string) => {
const key = resolveKey({
mode: resolvedMode,
channelId: resolvedChannelId,
});
const audio = getOrCreateAudio(key);
if (audio.src !== src) {
audio.src = src;
}
audio.currentTime = 0;
// 捕获浏览器因策略或中断导致的 play() 拒绝,避免控制台 Unhandled promise
audio.play().catch(() => {
// 可以在这里按需上报或忽略
});
emit({
key,
currentSrc: src,
isPlaying: true,
});
},
[resolvedMode, resolvedChannelId]
);
const stop = useCallback(() => {
const key = resolveKey({
mode: resolvedMode,
channelId: resolvedChannelId,
});
const audio = audioPool.get(key);
if (!audio) return;
audio.pause();
audio.currentTime = 0;
if (globalState.key === key) {
emit({
key,
currentSrc: globalState.currentSrc,
isPlaying: false,
});
}
}, [resolvedMode, resolvedChannelId]);
return {
...state,
play,
stop,
};
}