|
|
@@ -1,10 +1,10 @@
|
|
|
-import { useEffect, useRef, useState } from 'react';
|
|
|
+import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
import { getUserId, getStorage, STORAGE_KEYS } from '@/utils/storage';
|
|
|
import { getIntimacyStatus, getAvatarGifs, type AvatarGifSet } from '@/utils/api';
|
|
|
import { getOssUrl } from '@/utils/oss';
|
|
|
-import { px2vmin, px2vw } from '@/utils/viewport';
|
|
|
+import { px2vmin } from '@/utils/viewport';
|
|
|
import { wrapTrackedClick } from '@/utils/tracking';
|
|
|
import { AudioPlayer } from '@/utils/audioPlayer';
|
|
|
import './MissionEnergyStoneModal.css';
|
|
|
@@ -77,6 +77,54 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
|
|
|
const [showFuncMenuPopup, setShowFuncMenuPopup] = useState<boolean>(false);
|
|
|
const [showBondLevelModal, setShowBondLevelModal] = useState<boolean>(false);
|
|
|
const introAudioPlayerRef = useRef<AudioPlayer | null>(null);
|
|
|
+ const introCancelledRef = useRef<boolean>(false);
|
|
|
+
|
|
|
+ const INTRO_STEPS = [1, 3, 5, 8, 9, 10];
|
|
|
+ const hasIntroAudio = INTRO_STEPS.includes(step);
|
|
|
+
|
|
|
+ // 与 /chatbot 一致:先建立播放器,收到数据后在回调中播放(避免长 await 链打断“用户手势”延续)
|
|
|
+ const playIntroAudioForStep = useCallback(() => {
|
|
|
+ interface TutorialIntroAudioResponse {
|
|
|
+ success: boolean;
|
|
|
+ data?: {
|
|
|
+ audio?: string;
|
|
|
+ sampleRate?: number;
|
|
|
+ format?: string;
|
|
|
+ text?: string;
|
|
|
+ };
|
|
|
+ }
|
|
|
+ const base64ToUint8Array = (base64: string): Uint8Array => {
|
|
|
+ const binaryString = atob(base64);
|
|
|
+ const len = binaryString.length;
|
|
|
+ const bytes = new Uint8Array(len);
|
|
|
+ for (let i = 0; i < len; i += 1) {
|
|
|
+ bytes[i] = binaryString.charCodeAt(i);
|
|
|
+ }
|
|
|
+ return bytes;
|
|
|
+ };
|
|
|
+ Promise.all([import('@/utils/api')])
|
|
|
+ .then(([{ getApiBaseUrl, getApiHeaders }]) => {
|
|
|
+ const apiBaseUrl = getApiBaseUrl();
|
|
|
+ return fetch(`${apiBaseUrl}/api/tutorials/${step}/intro-audio`, {
|
|
|
+ method: 'GET',
|
|
|
+ headers: getApiHeaders(),
|
|
|
+ });
|
|
|
+ })
|
|
|
+ .then((response) => (response.ok ? response.json() : null))
|
|
|
+ .then((body: TutorialIntroAudioResponse | null) => {
|
|
|
+ if (!body?.success || !body.data?.audio || introCancelledRef.current) return;
|
|
|
+ const player = introAudioPlayerRef.current;
|
|
|
+ if (!player) return;
|
|
|
+ const audioBytes = base64ToUint8Array(body.data.audio);
|
|
|
+ const sampleRate = body.data.sampleRate ?? 24000;
|
|
|
+ player.addChunk(audioBytes, sampleRate).then(() => {
|
|
|
+ if (!introCancelledRef.current) player.markStreamEnd();
|
|
|
+ });
|
|
|
+ })
|
|
|
+ .catch(() => {
|
|
|
+ // 静默失败
|
|
|
+ });
|
|
|
+ }, [step]);
|
|
|
|
|
|
useEffect(() => {
|
|
|
// 确保每次进入组件 / 步骤变化时,优先关闭弹窗
|
|
|
@@ -111,88 +159,28 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
|
|
|
}, [step, showBondLevelModal]);
|
|
|
|
|
|
useEffect(() => {
|
|
|
- // 仅在指定步骤(1 / 3 / 5 / 8 / 9 / 10)播放字幕对应的引导语音
|
|
|
- if (step !== 1 && step !== 3 && step !== 5 && step !== 8 && step !== 9 && step !== 10) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- let cancelled = false;
|
|
|
-
|
|
|
- interface TutorialIntroAudioResponse {
|
|
|
- success: boolean;
|
|
|
- data?: {
|
|
|
- audio?: string;
|
|
|
- sampleRate?: number;
|
|
|
- format?: string;
|
|
|
- text?: string;
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- const base64ToUint8Array = (base64: string): Uint8Array => {
|
|
|
- const binaryString = atob(base64);
|
|
|
- const len = binaryString.length;
|
|
|
- const bytes = new Uint8Array(len);
|
|
|
- for (let i = 0; i < len; i += 1) {
|
|
|
- bytes[i] = binaryString.charCodeAt(i);
|
|
|
- }
|
|
|
- return bytes;
|
|
|
- };
|
|
|
-
|
|
|
- const playIntroAudio = async () => {
|
|
|
- try {
|
|
|
- const [{ getApiBaseUrl, getApiHeaders }] = await Promise.all([
|
|
|
- import('@/utils/api'),
|
|
|
- ]);
|
|
|
-
|
|
|
- const apiBaseUrl = getApiBaseUrl();
|
|
|
- const response = await fetch(`${apiBaseUrl}/api/tutorials/${step}/intro-audio`, {
|
|
|
- method: 'GET',
|
|
|
- headers: getApiHeaders(),
|
|
|
- });
|
|
|
+ if (!hasIntroAudio) return;
|
|
|
|
|
|
- if (!response.ok) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- const body = (await response.json()) as TutorialIntroAudioResponse;
|
|
|
- if (!body.success || !body.data?.audio) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- if (cancelled) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- if (!introAudioPlayerRef.current) {
|
|
|
- const player = new AudioPlayer();
|
|
|
- await player.initialize();
|
|
|
- introAudioPlayerRef.current = player;
|
|
|
- }
|
|
|
+ introCancelledRef.current = false;
|
|
|
|
|
|
- const player = introAudioPlayerRef.current;
|
|
|
- if (!player) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- const audioBytes = base64ToUint8Array(body.data.audio);
|
|
|
- const sampleRate = body.data.sampleRate ?? 24000;
|
|
|
-
|
|
|
- await player.addChunk(audioBytes, sampleRate);
|
|
|
- player.markStreamEnd();
|
|
|
- } catch {
|
|
|
- // 静默失败,避免影响新手教程体验
|
|
|
+ // 与 /chatbot 一致:先建立播放管线(AudioContext),再请求数据,在回调中播放
|
|
|
+ const run = async () => {
|
|
|
+ if (!introAudioPlayerRef.current) {
|
|
|
+ const player = new AudioPlayer();
|
|
|
+ await player.initialize();
|
|
|
+ introAudioPlayerRef.current = player;
|
|
|
}
|
|
|
+ playIntroAudioForStep();
|
|
|
};
|
|
|
-
|
|
|
- void playIntroAudio();
|
|
|
+ void run();
|
|
|
|
|
|
return () => {
|
|
|
- cancelled = true;
|
|
|
+ introCancelledRef.current = true;
|
|
|
if (introAudioPlayerRef.current) {
|
|
|
introAudioPlayerRef.current.stop();
|
|
|
}
|
|
|
};
|
|
|
- }, [step]);
|
|
|
+ }, [step, hasIntroAudio, playIntroAudioForStep]);
|
|
|
|
|
|
useEffect(() => {
|
|
|
const userId = getUserId();
|
|
|
@@ -843,7 +831,7 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
|
|
|
}}
|
|
|
>
|
|
|
<img
|
|
|
- src={FALLBACK_DEFAULT_AVATAR_GIF}
|
|
|
+ src={avatarGif}
|
|
|
alt={tCommon('a11y.avatar')}
|
|
|
style={{
|
|
|
position: 'absolute',
|