Skip to content

Commit 5dbc42c

Browse files
committed
fix: SVG动画改为用户手动播放
1 parent d832de1 commit 5dbc42c

6 files changed

Lines changed: 120 additions & 8 deletions

File tree

‎README.en.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ Practice statistics show total practice, accuracy and active months, with distri
146146

147147
### Explanations and demos
148148

149-
- **🎬 SVG animated explanations**: AI generates step-by-step SVG animations of the solution, with play/pause, seeking, replay and full screen. Animation text follows the interface language; Chinese and English versions are kept separately, and switching the language shows the matching version automatically. On by default; turn it off in **Settings → General**.
149+
- **🎬 SVG animated explanations**: AI generates step-by-step SVG animations of the solution that stay paused on the first frame when the page opens or the animation changes; click Play to start. The player supports play/pause, seeking, replay and full screen. Animation text follows the interface language; Chinese and English versions are kept separately, and switching the language shows the matching version automatically. On by default; turn it off in **Settings → General**.
150150
- **🔊 Voice narration**: click the speaker icon in the player; the animation waits for each step to be read before moving on, and the speed is adjustable. Choose the browser voice or cloud TTS (see [Cloud voice narration](#-cloud-voice-narration)). Older animations without a narration script read the on-screen subtitles, or click **Add narration** to have AI write only the script while keeping the picture unchanged.
151151
- **📐 GeoGebra demos**: for math and physics questions, AI can generate interactive GeoGebra figures. Off by default; turn it on in **Settings → General**.
152152
- Generated animations and GeoGebra demos can be deleted (with confirmation).

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@
146146

147147
### 讲解与演示
148148

149-
- **🎬 SVG 动画讲解**:AI 按解题思路生成分步骤的 SVG 动画,支持播放/暂停、拖动进度、重播和全屏;动画文字与界面语言一致,中英文版本分别保存,切换语言时自动显示对应版本。默认开启,可在「设置 → 通用」中关闭。
149+
- **🎬 SVG 动画讲解**:AI 按解题思路生成分步骤的 SVG 动画,打开页面或切换动画时停在首帧,点击播放后才开始;支持播放/暂停、拖动进度、重播和全屏;动画文字与界面语言一致,中英文版本分别保存,切换语言时自动显示对应版本。默认开启,可在「设置 → 通用」中关闭。
150150
- **🔊 语音讲解**:点击播放器上的喇叭开启,画面等语音读完再进入下一步,语速可调。语音可选浏览器本地语音或云端 TTS(见[云端语音讲解](#-云端语音讲解));旧动画没有讲解稿时朗读画面字幕,也可点击「补充讲解稿」由 AI 只补写讲解稿,画面保持不变。
151151
- **📐 GeoGebra 动态演示**:数学、物理题可由 AI 生成交互式 GeoGebra 图形。默认关闭,可在「设置 → 通用」中开启。
152152
- 已生成的动画讲解和 GeoGebra 演示都可删除(需二次确认)。

‎doc/wiki/User-Guide.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
## 配图和动态讲解
5353

5454
- **SVG 配图**:由 AI 根据题目生成静态 SVG,供详情、练习和打印使用;原图可单独查看。
55-
- **SVG 动画讲解**:按解题思路分步骤演示,支持暂停、拖动进度、重播和全屏,个人设置默认开启。点击播放器上的喇叭开启语音讲解(默认关闭,开启后会记住),朗读每一步(默认用浏览器的语音合成;管理员开启云端语音后由服务端合成,所有设备音色一致),画面等语音读完再进入下一步(等待时停在当前步骤完整的画面上,动画外框高亮提示),语速可在 0.75×~1.5× 间切换。旧动画没有讲解稿时朗读画面底部的字幕;点击动画上方的「补充讲解稿」可由 AI 按现有画面补写讲解稿,画面不变,不需要重新生成动画(计 1 次 AI 动画讲解用量)。已有讲解稿时按钮显示为「重写讲解稿」。使用云端语音时,首次开启语音会一次合成整个动画的讲解语音,动画上方另有「重新生成语音」可重新合成;两者都会登记到「后台任务」中显示进度(消耗语音服务商额度,不计 AI 次数)。已生成云端语音的动画再次打开时默认开启语音讲解。动画文字跟随界面语言,中英文版本分别生成和保存,切换语言时自动显示对应版本。
55+
- **SVG 动画讲解**:按解题思路分步骤演示,打开页面或切换动画时停在首帧,须点击播放才开始,支持暂停、拖动进度、重播和全屏,个人设置默认开启。点击播放器上的喇叭开启语音讲解(默认关闭,开启后会记住),朗读每一步(默认用浏览器的语音合成;管理员开启云端语音后由服务端合成,所有设备音色一致),画面等语音读完再进入下一步(等待时停在当前步骤完整的画面上,动画外框高亮提示),语速可在 0.75×~1.5× 间切换。旧动画没有讲解稿时朗读画面底部的字幕;点击动画上方的「补充讲解稿」可由 AI 按现有画面补写讲解稿,画面不变,不需要重新生成动画(计 1 次 AI 动画讲解用量)。已有讲解稿时按钮显示为「重写讲解稿」。使用云端语音时,首次开启语音会一次合成整个动画的讲解语音,动画上方另有「重新生成语音」可重新合成;两者都会登记到「后台任务」中显示进度(消耗语音服务商额度,不计 AI 次数)。已生成云端语音的动画再次打开时默认开启语音讲解。动画文字跟随界面语言,中英文版本分别生成和保存,切换语言时自动显示对应版本。
5656
- **GeoGebra 动态演示**:供数学、物理题生成交互图形,个人设置默认关闭。
5757

5858
已生成的动画和 GeoGebra 演示可在确认后删除。生成的图形和解答需要人工核对,尤其是几何位置、计数过程和结论。

‎doc/wiki/en/User-Guide-en.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ Unrated questions are not assigned a paper level by the default rule alone. Rege
5252
## Figures and animated explanations
5353

5454
- **SVG figures**: static SVG generated by the AI from the question, used on the detail page, in practice and in printouts; the original image can still be viewed separately.
55-
- **SVG animated explanations**: step-by-step demonstrations of the solution with pause, seeking, replay and full screen; enabled by default in personal settings. Click the speaker icon in the player to turn on voice narration (off by default and remembered once on). Each step is read aloud (by the browser's speech synthesis by default; when an admin turns on cloud voice, the server synthesizes it so every device hears the same voice), the animation waits for the narration before moving on (it pauses on the complete current step and highlights the frame border while waiting), and the speed cycles between 0.75× and 1.5×. Older animations without a narration script read the subtitles at the bottom of the picture. Click **Add narration** above the animation to have AI write a script for the existing picture without regenerating the animation (counts as one animated-explanation AI use). When a script exists, the button reads **Rewrite narration**. With cloud voice on, turning on narration for the first time synthesizes the whole animation's voice at once, and **Regenerate voice** above the animation synthesizes it again; both appear in background tasks with progress (they use the speech provider's quota, not the AI daily limit). Animations whose cloud voice has been generated open with narration on. Animation text follows the interface language; Chinese and English versions are generated and saved separately, and switching the language shows the matching version automatically.
55+
- **SVG animated explanations**: the animation stays paused on the first frame when opened or changed; click Play to start. Step-by-step demonstrations of the solution with pause, seeking, replay and full screen; enabled by default in personal settings. Click the speaker icon in the player to turn on voice narration (off by default and remembered once on). Each step is read aloud (by the browser's speech synthesis by default; when an admin turns on cloud voice, the server synthesizes it so every device hears the same voice), the animation waits for the narration before moving on (it pauses on the complete current step and highlights the frame border while waiting), and the speed cycles between 0.75× and 1.5×. Older animations without a narration script read the subtitles at the bottom of the picture. Click **Add narration** above the animation to have AI write a script for the existing picture without regenerating the animation (counts as one animated-explanation AI use). When a script exists, the button reads **Rewrite narration**. With cloud voice on, turning on narration for the first time synthesizes the whole animation's voice at once, and **Regenerate voice** above the animation synthesizes it again; both appear in background tasks with progress (they use the speech provider's quota, not the AI daily limit). Animations whose cloud voice has been generated open with narration on. Animation text follows the interface language; Chinese and English versions are generated and saved separately, and switching the language shows the matching version automatically.
5656
- **GeoGebra interactive demos**: interactive figures for math and physics questions; disabled by default in personal settings.
5757

5858
Generated animations and GeoGebra demos can be deleted after confirmation. Check generated figures and solutions manually, especially geometric positions, counting steps and conclusions.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { act } from 'react';
2+
import { createRoot } from 'react-dom/client';
3+
import type { Root } from 'react-dom/client';
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
import { SvgAnimationPlayer } from '@/components/svg-animation-player';
6+
import { LanguageProvider } from '@/contexts/LanguageContext';
7+
8+
const narration = vi.hoisted(() => ({ stop: vi.fn(), update: vi.fn(), prime: vi.fn() }));
9+
vi.mock('@/lib/use-svg-narration', () => ({
10+
useSvgNarration: () => ({ ...narration, enabled: true, holding: false, speaking: false }),
11+
}));
12+
13+
let container: HTMLDivElement;
14+
let root: Root;
15+
const svg = '<svg viewBox="0 0 800 500"><animate attributeName="x" dur="10s"/></svg>';
16+
17+
beforeEach(() => {
18+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
19+
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1));
20+
vi.stubGlobal('cancelAnimationFrame', vi.fn());
21+
container = document.createElement('div');
22+
document.body.appendChild(container);
23+
root = createRoot(container);
24+
});
25+
26+
afterEach(async () => {
27+
await act(async () => root.unmount());
28+
container.remove();
29+
vi.unstubAllGlobals();
30+
});
31+
32+
async function render(source = svg) {
33+
await act(async () => root.render(<LanguageProvider><SvgAnimationPlayer svg={source} /></LanguageProvider>));
34+
}
35+
36+
async function load() {
37+
const iframe = container.querySelector('iframe')!;
38+
const element = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
39+
const timeline = { pauseAnimations: vi.fn(), unpauseAnimations: vi.fn(), setCurrentTime: vi.fn(), getCurrentTime: () => 0 };
40+
Object.assign(element, timeline);
41+
const cssAnimation = { pause: vi.fn(), play: vi.fn(), currentTime: 200 };
42+
vi.spyOn(iframe.contentDocument!, 'querySelector').mockReturnValue(element);
43+
Object.assign(iframe.contentDocument!, { getAnimations: () => [cssAnimation] });
44+
await act(async () => iframe.dispatchEvent(new Event('load')));
45+
return { iframe, timeline, cssAnimation };
46+
}
47+
48+
function button(label: string) {
49+
return container.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)!;
50+
}
51+
52+
describe('SVG 动画必须手工播放', () => {
53+
it('加载后 SMIL 和 CSS 停在首帧,画面与朗读均不自动开始', async () => {
54+
await render();
55+
expect(button('播放').disabled).toBe(true);
56+
const { iframe, timeline, cssAnimation } = await load();
57+
58+
expect(timeline.pauseAnimations).toHaveBeenCalled();
59+
expect(timeline.setCurrentTime).toHaveBeenCalledWith(0);
60+
expect(cssAnimation.pause).toHaveBeenCalled();
61+
expect(cssAnimation.currentTime).toBe(0);
62+
expect(timeline.unpauseAnimations).not.toHaveBeenCalled();
63+
expect(cssAnimation.play).not.toHaveBeenCalled();
64+
expect(narration.prime).not.toHaveBeenCalled();
65+
expect(requestAnimationFrame).not.toHaveBeenCalled();
66+
expect(button('播放').disabled).toBe(false);
67+
expect(iframe.style.visibility).toBe('visible');
68+
});
69+
70+
it('点击播放才恢复两条时间轴和朗读,点击暂停后停止', async () => {
71+
await render();
72+
const { timeline, cssAnimation } = await load();
73+
await act(async () => button('播放').click());
74+
expect(timeline.unpauseAnimations).toHaveBeenCalledOnce();
75+
expect(cssAnimation.play).toHaveBeenCalledOnce();
76+
expect(narration.prime).toHaveBeenCalledOnce();
77+
expect(requestAnimationFrame).toHaveBeenCalledOnce();
78+
await act(async () => button('暂停').click());
79+
expect(timeline.pauseAnimations).toHaveBeenCalledTimes(2);
80+
expect(cssAnimation.pause).toHaveBeenCalledTimes(2);
81+
expect(button('播放')).toBeTruthy();
82+
});
83+
84+
it('切换或重新生成动画后重新停在首帧', async () => {
85+
await render();
86+
await load();
87+
await act(async () => button('播放').click());
88+
await render(svg.replace('10s', '12s'));
89+
expect(container.querySelector('iframe')!.style.visibility).toBe('hidden');
90+
const { timeline, cssAnimation } = await load();
91+
expect(timeline.setCurrentTime).toHaveBeenCalledWith(0);
92+
expect(timeline.unpauseAnimations).not.toHaveBeenCalled();
93+
expect(cssAnimation.play).not.toHaveBeenCalled();
94+
expect(button('播放')).toBeTruthy();
95+
});
96+
97+
it('手工点击重播会从头开始播放', async () => {
98+
await render();
99+
const { timeline, cssAnimation } = await load();
100+
await act(async () => button('重播').click());
101+
expect(timeline.setCurrentTime).toHaveBeenLastCalledWith(0);
102+
expect(timeline.unpauseAnimations).toHaveBeenCalledOnce();
103+
expect(cssAnimation.play).toHaveBeenCalledOnce();
104+
});
105+
});

‎src/components/svg-animation-player.tsx‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export function SvgAnimationPlayer({ svg, errorItemId }: SvgAnimationPlayerProps
3333
const containerRef = useRef<HTMLDivElement>(null);
3434
const iframeRef = useRef<HTMLIFrameElement>(null);
3535
const svgRef = useRef<SVGSVGElement | null>(null);
36+
const [loadedSvg, setLoadedSvg] = useState<string | null>(null);
3637
const [playing, setPlaying] = useState(false);
3738
const [current, setCurrent] = useState(0);
3839
const [nativeFullscreen, setNativeFullscreen] = useState(false);
@@ -98,10 +99,14 @@ export function SvgAnimationPlayer({ svg, errorItemId }: SvgAnimationPlayerProps
9899

99100
const handleLoad = () => {
100101
svgRef.current = iframeRef.current?.contentDocument?.querySelector("svg") ?? null;
101-
setSubtitleNarration(svgRef.current ? extractSubtitleNarration(svgRef.current) : null);
102102
stopNarration(0);
103+
// SMIL 和 CSS 时间轴都停在首帧,只有用户点击播放或重播才恢复。
104+
pauseAnimation();
105+
svgRef.current?.setCurrentTime(0);
106+
cssAnimations().forEach((animation) => { animation.currentTime = 0; });
107+
setSubtitleNarration(svgRef.current ? extractSubtitleNarration(svgRef.current) : null);
103108
setCurrent(0);
104-
setPlaying(!!svgRef.current);
109+
setLoadedSvg(svgRef.current ? svg : null);
105110
};
106111

107112
const toggleFullscreen = async () => {
@@ -186,9 +191,9 @@ export function SvgAnimationPlayer({ svg, errorItemId }: SvgAnimationPlayerProps
186191
waitingForVoice && "border-primary ring-2 ring-primary/40"
187192
)}
188193
// 全屏时按宽高比放到最大,留出控制栏的高度
189-
style={fullscreen
194+
style={{ visibility: loadedSvg === svg ? "visible" : "hidden", ...(fullscreen
190195
? { aspectRatio, width: `min(100%, calc((100dvh - 96px) * ${aspectRatio}))` }
191-
: { aspectRatio }}
196+
: { aspectRatio }) }}
192197
/>
193198
<div className={cn("mx-auto flex w-full items-center gap-2", fullscreen ? "max-w-5xl text-white" : "max-w-3xl")}>
194199
<Button
@@ -197,6 +202,7 @@ export function SvgAnimationPlayer({ svg, errorItemId }: SvgAnimationPlayerProps
197202
// 全屏时控制栏文字为白色,播放按钮是白底,图标需保持深色
198203
className="h-8 w-8 shrink-0 p-0 text-foreground"
199204
onClick={playing ? pause : play}
205+
disabled={loadedSvg !== svg}
200206
aria-label={playing ? t.svgAnimation.pause : t.svgAnimation.play}
201207
title={playing ? t.svgAnimation.pause : t.svgAnimation.play}
202208
>
@@ -207,6 +213,7 @@ export function SvgAnimationPlayer({ svg, errorItemId }: SvgAnimationPlayerProps
207213
size="sm"
208214
className={cn("h-8 w-8 shrink-0 p-0", fullscreen && "text-white hover:bg-white/20 hover:text-white")}
209215
onClick={() => { seek(0); play(); }}
216+
disabled={loadedSvg !== svg}
210217
aria-label={t.svgAnimation.replay}
211218
title={t.svgAnimation.replay}
212219
>

0 commit comments

Comments
 (0)