BeginPage.tsx 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060
  1. import { useEffect, useRef, useState } from 'react';
  2. import { useNavigate, useSearchParams } from 'react-router-dom';
  3. import { message as antMessage } from 'antd';
  4. import videojs from 'video.js';
  5. import { setStorage, STORAGE_KEYS, markRouteVisited, getVisitedRoutes, getUserId, getStorage, setUserId, setUserLevel } from '@/utils/storage';
  6. import { updateUserUIState, createUser, getUserInfo } from '@/utils/api';
  7. import 'video.js/dist/video-js.css';
  8. import { getOssUrl } from '@/utils/oss';
  9. import { px2vmin } from '@/utils/viewport';
  10. const lumiImage = getOssUrl('Lumi-image.png');
  11. // 判断是否为后端生成的默认用户名
  12. const isBackendGeneratedName = (name: string): boolean => /^User\d+$/.test(name.trim());
  13. // 扩展 videojs 的类型定义
  14. type VideoJsPlayer = ReturnType<typeof videojs>;
  15. type WelcomeViewState = 'welcome' | 'loading' | 'playing';
  16. export const BeginPage = () => {
  17. const navigate = useNavigate();
  18. const [searchParams] = useSearchParams();
  19. const videoRef = useRef<HTMLVideoElement>(null);
  20. const playerRef = useRef<VideoJsPlayer | null>(null);
  21. const [viewState, setViewState] = useState<WelcomeViewState>('welcome');
  22. const [isLoading, setIsLoading] = useState(false);
  23. const [agreementChecked, setAgreementChecked] = useState(true);
  24. const isVideoReadyRef = useRef(false); // 视频是否已加载完成
  25. const loadingStartTimeRef = useRef<number | null>(null); // 加载开始时间
  26. const viewStateRef = useRef<WelcomeViewState>('welcome'); // 用于在回调中访问最新状态
  27. const hasNavigatedRef = useRef(false); // 是否已经跳转到 /naming,防止重复执行
  28. const checkIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); // 检查定时器
  29. const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); // 兜底定时器
  30. const hasShownTimeoutTipRef = useRef(false); // 是否已经提示过"响应超时"
  31. // 处理错误参数(如QQ登录时用户未注册)
  32. useEffect(() => {
  33. const error = searchParams.get('error');
  34. const oauth = searchParams.get('oauth');
  35. if (error === 'user_not_registered' && oauth === 'qq') {
  36. antMessage.error('用户未注册,请先注册');
  37. // 清除URL参数
  38. navigate('/welcome', { replace: true });
  39. }
  40. }, [searchParams, navigate]);
  41. useEffect(() => {
  42. console.log('[BeginPage] init effect start');
  43. // 记录用户当前所在页面
  44. const ok = setStorage(STORAGE_KEYS.CURRENT_PAGE, 'login');
  45. console.log('[BeginPage] set CURRENT_PAGE=login result:', ok);
  46. if (!getStorage(STORAGE_KEYS.USER_LEVEL)) {
  47. setStorage(STORAGE_KEYS.USER_LEVEL, '0');
  48. }
  49. // 默认标记为未登录、未分享
  50. setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'false');
  51. setStorage(STORAGE_KEYS.HAS_SHARED_AVATAR, 'false');
  52. // 记录路由访问
  53. markRouteVisited('/login');
  54. console.log('[BeginPage] visited routes after markRouteVisited:', getVisitedRoutes());
  55. // 将页面与路由状态同步到后端(如果已有 user_id)
  56. const userId = getUserId();
  57. if (userId) {
  58. const visited = getVisitedRoutes();
  59. updateUserUIState(userId, {
  60. current_page: 'login',
  61. visited_routes: visited,
  62. }).catch((error) => {
  63. console.warn('[BeginPage] 同步页面状态失败:', error);
  64. if (!hasShownTimeoutTipRef.current) {
  65. hasShownTimeoutTipRef.current = true;
  66. antMessage.warning('响应超时,请重试');
  67. }
  68. });
  69. }
  70. // 🚀 提前预加载视频资源(使用 link preload)
  71. const videoUrl = import.meta.env.VITE_WELCOME_VIDEO_URL || getOssUrl('Lyrria-video.mp4');
  72. const preloadLink = document.createElement('link');
  73. preloadLink.rel = 'preload';
  74. preloadLink.as = 'video';
  75. preloadLink.href = videoUrl;
  76. preloadLink.crossOrigin = 'anonymous';
  77. document.head.appendChild(preloadLink);
  78. console.log('[WelcomePage] 已添加视频预加载链接:', videoUrl);
  79. // 清理函数
  80. return () => {
  81. // 清理预加载链接
  82. if (preloadLink.parentNode) {
  83. preloadLink.parentNode.removeChild(preloadLink);
  84. }
  85. };
  86. }, []);
  87. // 页面打开后,后台预加载视频(在 video 元素挂载后)
  88. useEffect(() => {
  89. // 使用 setTimeout 确保 video 元素已经挂载到 DOM
  90. const initTimer = setTimeout(() => {
  91. if (!playerRef.current && videoRef.current) {
  92. console.log('[WelcomePage] 后台预加载视频...');
  93. const player = videojs(videoRef.current, {
  94. controls: false,
  95. autoplay: false,
  96. preload: 'auto', // 预加载视频
  97. fluid: true,
  98. aspectRatio: '16:9',
  99. muted: false,
  100. nativeControlsForTouch: false,
  101. playsinline: true,
  102. bigPlayButton: false,
  103. textTrackDisplay: false,
  104. posterImage: false,
  105. errorDisplay: false,
  106. });
  107. playerRef.current = player;
  108. // 配置原生 video 元素
  109. const videoElement = player.el().querySelector('video') as HTMLVideoElement;
  110. if (videoElement) {
  111. videoElement.removeAttribute('controls');
  112. videoElement.controls = false;
  113. videoElement.setAttribute('playsinline', '');
  114. videoElement.setAttribute('webkit-playsinline', '');
  115. videoElement.setAttribute('x5-playsinline', '');
  116. videoElement.setAttribute('x5-video-player-type', 'h5');
  117. videoElement.setAttribute('x5-video-player-fullscreen', 'false');
  118. videoElement.setAttribute('controlsList', 'nodownload nofullscreen noremoteplayback');
  119. videoElement.setAttribute('disablePictureInPicture', 'true');
  120. // 隐藏视频元素(后台加载)
  121. videoElement.style.visibility = 'hidden';
  122. videoElement.style.opacity = '0';
  123. videoElement.style.position = 'absolute';
  124. videoElement.style.top = '-9999px';
  125. // 🚀 强制开始加载视频(立即开始预加载)
  126. try {
  127. videoElement.load(); // 强制开始加载
  128. console.log('[WelcomePage] 已强制开始加载视频');
  129. } catch (error) {
  130. console.warn('[WelcomePage] 强制加载失败(可能已开始加载):', error);
  131. }
  132. // 监听视频加载状态
  133. const handleCanPlayThrough = () => {
  134. console.log('[WelcomePage] 视频已加载完成,可以播放');
  135. isVideoReadyRef.current = true;
  136. // 如果正在加载状态,检查是否可以开始播放
  137. if (viewStateRef.current === 'loading' && loadingStartTimeRef.current) {
  138. checkAndStartPlayback();
  139. }
  140. };
  141. const handleError = (error: Event) => {
  142. console.error('[WelcomePage] 视频加载错误:', error);
  143. const videoEl = error.target as HTMLVideoElement;
  144. if (videoEl && videoEl.error) {
  145. console.error('[WelcomePage] 视频错误详情:', {
  146. code: videoEl.error?.code,
  147. message: videoEl.error?.message,
  148. });
  149. }
  150. // 即使出错也尝试继续(可能是网络问题,稍后重试)
  151. isVideoReadyRef.current = false;
  152. };
  153. const handleLoadedData = () => {
  154. console.log('[WelcomePage] 视频数据已加载');
  155. };
  156. const handleLoadedMetadata = () => {
  157. console.log('[WelcomePage] 视频元数据已加载');
  158. };
  159. // 绑定事件
  160. videoElement.addEventListener('canplaythrough', handleCanPlayThrough);
  161. videoElement.addEventListener('error', handleError);
  162. videoElement.addEventListener('loadeddata', handleLoadedData);
  163. videoElement.addEventListener('loadedmetadata', handleLoadedMetadata);
  164. // 保存事件处理器以便清理
  165. (player as any)._canPlayThroughHandler = handleCanPlayThrough;
  166. (player as any)._errorHandler = handleError;
  167. (player as any)._loadedDataHandler = handleLoadedData;
  168. (player as any)._loadedMetadataHandler = handleLoadedMetadata;
  169. }
  170. // 持续监控并移除 controls 属性
  171. const controlWatcher = setInterval(() => {
  172. const playerEl = player.el();
  173. if (!playerEl) return;
  174. const videoEl = playerEl.querySelector('video') as HTMLVideoElement;
  175. if (videoEl && videoEl.hasAttribute('controls')) {
  176. videoEl.removeAttribute('controls');
  177. videoEl.controls = false;
  178. }
  179. }, 100);
  180. (player as any)._controlWatcher = controlWatcher;
  181. // 绑定播放结束事件
  182. const handleEnded = () => {
  183. console.log('[WelcomePage] 视频播放完成,跳转到 /naming');
  184. navigate('/naming');
  185. };
  186. player.on('ended', handleEnded);
  187. (player as any)._endedHandler = handleEnded;
  188. }
  189. }, 100);
  190. // 清理函数
  191. return () => {
  192. clearTimeout(initTimer);
  193. if (playerRef.current) {
  194. const player = playerRef.current;
  195. const watcher = (player as any)._controlWatcher;
  196. if (watcher) {
  197. clearInterval(watcher);
  198. }
  199. const endedHandler = (player as any)._endedHandler;
  200. if (endedHandler) {
  201. player.off('ended', endedHandler);
  202. }
  203. // 清理视频元素事件监听器
  204. const videoElement = player.el()?.querySelector('video') as HTMLVideoElement;
  205. if (videoElement) {
  206. const canPlayThroughHandler = (player as any)._canPlayThroughHandler;
  207. const errorHandler = (player as any)._errorHandler;
  208. const loadedDataHandler = (player as any)._loadedDataHandler;
  209. const loadedMetadataHandler = (player as any)._loadedMetadataHandler;
  210. const videoEndedHandler = (player as any)._videoEndedHandler;
  211. if (canPlayThroughHandler) {
  212. videoElement.removeEventListener('canplaythrough', canPlayThroughHandler);
  213. }
  214. if (errorHandler) {
  215. videoElement.removeEventListener('error', errorHandler);
  216. }
  217. if (loadedDataHandler) {
  218. videoElement.removeEventListener('loadeddata', loadedDataHandler);
  219. }
  220. if (loadedMetadataHandler) {
  221. videoElement.removeEventListener('loadedmetadata', loadedMetadataHandler);
  222. }
  223. if (videoEndedHandler) {
  224. videoElement.removeEventListener('ended', videoEndedHandler);
  225. }
  226. }
  227. // 注意:不在这里销毁播放器,因为视频需要保持加载状态
  228. }
  229. };
  230. }, [navigate, viewState]);
  231. // 更新 viewState 时同步更新 ref
  232. useEffect(() => {
  233. viewStateRef.current = viewState;
  234. }, [viewState]);
  235. // 组件卸载时清理定时器
  236. useEffect(() => {
  237. return () => {
  238. if (checkIntervalRef.current) {
  239. clearInterval(checkIntervalRef.current);
  240. checkIntervalRef.current = null;
  241. }
  242. if (fallbackTimerRef.current) {
  243. clearTimeout(fallbackTimerRef.current);
  244. fallbackTimerRef.current = null;
  245. }
  246. };
  247. }, []);
  248. // 检查并开始播放(结合加载状态和最小缓冲时间)
  249. const checkAndStartPlayback = () => {
  250. if (!playerRef.current || viewStateRef.current !== 'loading') {
  251. return;
  252. }
  253. const now = Date.now();
  254. const minLoadingTime = 3000; // 最小加载时间3秒
  255. const elapsed = loadingStartTimeRef.current
  256. ? now - loadingStartTimeRef.current
  257. : 0;
  258. const remainingTime = Math.max(0, minLoadingTime - elapsed);
  259. // 如果视频已准备好且达到最小加载时间,开始播放
  260. if (isVideoReadyRef.current && elapsed >= minLoadingTime) {
  261. console.log('[WelcomePage] 视频已准备好,开始播放');
  262. // 清理定时器
  263. if (checkIntervalRef.current) {
  264. clearInterval(checkIntervalRef.current);
  265. checkIntervalRef.current = null;
  266. }
  267. if (fallbackTimerRef.current) {
  268. clearTimeout(fallbackTimerRef.current);
  269. fallbackTimerRef.current = null;
  270. }
  271. setIsLoading(false);
  272. setViewState('playing');
  273. } else if (isVideoReadyRef.current && remainingTime > 0) {
  274. // 视频已准备好,但还需要等待最小加载时间
  275. console.log(`[WelcomePage] 视频已准备好,等待 ${remainingTime}ms 后播放`);
  276. // 清理检查定时器
  277. if (checkIntervalRef.current) {
  278. clearInterval(checkIntervalRef.current);
  279. checkIntervalRef.current = null;
  280. }
  281. setTimeout(() => {
  282. if (viewStateRef.current === 'loading') {
  283. setIsLoading(false);
  284. setViewState('playing');
  285. }
  286. }, remainingTime);
  287. } else if (!isVideoReadyRef.current && elapsed >= minLoadingTime) {
  288. // 达到最小加载时间但视频未准备好,再等待一段时间
  289. console.log('[WelcomePage] 达到最小加载时间,但视频未完全准备好,继续等待...');
  290. // 清理检查定时器
  291. if (checkIntervalRef.current) {
  292. clearInterval(checkIntervalRef.current);
  293. checkIntervalRef.current = null;
  294. }
  295. const waitTimer = setTimeout(() => {
  296. if (viewStateRef.current === 'loading') {
  297. if (isVideoReadyRef.current) {
  298. setIsLoading(false);
  299. setViewState('playing');
  300. } else {
  301. // 即使未完全准备好也尝试播放(可能是网络慢)
  302. console.warn('[WelcomePage] 视频未完全准备好,尝试播放');
  303. setIsLoading(false);
  304. setViewState('playing');
  305. }
  306. }
  307. }, 2000); // 额外等待2秒
  308. (playerRef.current as any)._waitTimer = waitTimer;
  309. }
  310. };
  311. // 当进入播放状态时,显示视频并开始播放
  312. useEffect(() => {
  313. if (viewState === 'playing' && playerRef.current) {
  314. console.log('[WelcomePage] 开始播放视频');
  315. const player = playerRef.current;
  316. if (!player) {
  317. return;
  318. }
  319. const videoElement = player.el().querySelector('video') as HTMLVideoElement;
  320. if (videoElement) {
  321. // 显示视频元素
  322. videoElement.style.visibility = 'visible';
  323. videoElement.style.opacity = '1';
  324. videoElement.style.position = 'absolute';
  325. videoElement.style.top = '0';
  326. videoElement.style.left = '0';
  327. videoElement.style.width = '100%';
  328. videoElement.style.height = '100%';
  329. videoElement.style.zIndex = '2';
  330. // 🚀 在视频播放期间预加载用户数据,避免进入 naming 页面时卡顿
  331. const preloadUserData = async () => {
  332. console.log('[WelcomePage] 开始预加载用户数据...');
  333. try {
  334. // 确保存在 userId:如果没有,则创建用户
  335. let userId = getUserId();
  336. if (!userId) {
  337. try {
  338. const response = await createUser();
  339. if (response.success && response.data?.user_id) {
  340. userId = response.data.user_id;
  341. setUserId(userId);
  342. // 保存用户名到 localStorage
  343. if (response.data.username) {
  344. if (!isBackendGeneratedName(response.data.username)) {
  345. setStorage(STORAGE_KEYS.USER_NAME, response.data.username);
  346. }
  347. }
  348. if (typeof response.data.user_level === 'number') {
  349. setUserLevel(response.data.user_level);
  350. }
  351. console.log('[WelcomePage] ✅ 预加载:创建用户成功', userId);
  352. }
  353. } catch (error) {
  354. console.error('[WelcomePage] 预加载:创建用户失败', error);
  355. }
  356. }
  357. // 如果 localStorage 中没有保存的用户名,从后端获取
  358. const savedName = getStorage(STORAGE_KEYS.USER_NAME);
  359. if (!savedName) {
  360. const user_id = userId || getUserId();
  361. if (user_id) {
  362. try {
  363. const response = await getUserInfo(user_id);
  364. if (response.success && response.data) {
  365. const { username, bot_name } = response.data;
  366. if (typeof response.data.user_level === 'number') {
  367. setUserLevel(response.data.user_level);
  368. }
  369. // 保存用户名到 localStorage
  370. if (username) {
  371. if (!isBackendGeneratedName(username)) {
  372. setStorage(STORAGE_KEYS.USER_NAME, username);
  373. }
  374. }
  375. // 保存 bot_name 到 localStorage
  376. if (bot_name) {
  377. setStorage(STORAGE_KEYS.LUMI_NAME, bot_name);
  378. }
  379. console.log('[WelcomePage] ✅ 预加载:获取用户信息成功');
  380. }
  381. } catch (error) {
  382. console.error('[WelcomePage] 预加载:获取用户信息失败', error);
  383. }
  384. }
  385. }
  386. // 更新用户 UI 状态(非阻塞)
  387. if (userId) {
  388. const visited = getVisitedRoutes();
  389. updateUserUIState(userId, {
  390. current_page: 'naming',
  391. visited_routes: visited,
  392. }).catch(() => {
  393. // 同步失败不影响前端流程
  394. });
  395. }
  396. } catch (error) {
  397. console.error('[WelcomePage] 预加载用户数据异常:', error);
  398. }
  399. };
  400. // 立即开始预加载(不阻塞视频播放)
  401. preloadUserData();
  402. // 🚀 统一的安全跳转函数,避免多次 navigate
  403. const safeNavigateToNaming = () => {
  404. if (hasNavigatedRef.current) {
  405. return;
  406. }
  407. hasNavigatedRef.current = true;
  408. console.log('[WelcomePage] 触发跳转到 /naming');
  409. if (checkIntervalRef.current) {
  410. clearInterval(checkIntervalRef.current);
  411. checkIntervalRef.current = null;
  412. }
  413. if (fallbackTimerRef.current) {
  414. clearTimeout(fallbackTimerRef.current);
  415. fallbackTimerRef.current = null;
  416. }
  417. navigate('/naming');
  418. };
  419. // 绑定视频播放结束事件(原生事件,确保可靠)
  420. const handleVideoEnded = () => {
  421. console.log('[WelcomePage] 视频播放完成(video ended),准备跳转');
  422. safeNavigateToNaming();
  423. };
  424. videoElement.addEventListener('ended', handleVideoEnded);
  425. // 保存处理器以便清理
  426. (player as any)._videoEndedHandler = handleVideoEnded;
  427. // 🔁 兼容部分浏览器不触发 ended 的情况:监听播放进度,接近结尾时跳转
  428. const handleTimeUpdate = () => {
  429. try {
  430. const duration = videoElement.duration;
  431. const currentTime = videoElement.currentTime;
  432. if (!duration || Number.isNaN(duration) || duration <= 0) {
  433. return;
  434. }
  435. const progress = currentTime / duration;
  436. if (progress >= 0.995) {
  437. console.log('[WelcomePage] 视频已播放到 99.5% 以上,触发兜底跳转');
  438. safeNavigateToNaming();
  439. }
  440. } catch (error) {
  441. console.warn('[WelcomePage] timeupdate 处理异常:', error);
  442. }
  443. };
  444. videoElement.addEventListener('timeupdate', handleTimeUpdate);
  445. (player as any)._timeUpdateHandler = handleTimeUpdate;
  446. // 确保视频未静音(用户已交互,应该可以播放声音)
  447. player.muted(false);
  448. videoElement.muted = false;
  449. videoElement.volume = 1.0;
  450. // 由于用户已点击(交互),移动端Chrome应该允许播放声音
  451. // 尝试播放(有声)
  452. const playPromise = player.play();
  453. if (playPromise) {
  454. playPromise
  455. .then(() => {
  456. console.log('[WelcomePage] 视频播放成功(有声)');
  457. // 确保未静音
  458. player.muted(false);
  459. videoElement.muted = false;
  460. videoElement.volume = 1.0;
  461. })
  462. .catch((error: unknown) => {
  463. console.warn('[WelcomePage] 有声播放失败,尝试静音播放:', error);
  464. // 如果有声播放失败,尝试静音播放
  465. player.muted(true);
  466. videoElement.muted = true;
  467. const mutedPlayPromise = player.play();
  468. if (mutedPlayPromise) {
  469. mutedPlayPromise
  470. .then(() => {
  471. console.log('[WelcomePage] 静音播放成功,尝试恢复声音');
  472. // 静音播放成功后,尝试恢复声音(需要用户再次交互)
  473. // 但由于用户已点击,应该可以恢复
  474. setTimeout(() => {
  475. try {
  476. player.muted(false);
  477. videoElement.muted = false;
  478. videoElement.volume = 1.0;
  479. console.log('[WelcomePage] 已尝试恢复声音');
  480. } catch (e) {
  481. console.warn('[WelcomePage] 恢复声音失败:', e);
  482. }
  483. }, 100);
  484. })
  485. .catch((err: unknown) => {
  486. console.error('[WelcomePage] 静音播放也失败:', err);
  487. });
  488. }
  489. });
  490. }
  491. }
  492. // 清理函数:移除事件监听器
  493. return () => {
  494. if (playerRef.current) {
  495. const player = playerRef.current;
  496. const videoElement = player.el()?.querySelector('video') as HTMLVideoElement;
  497. if (videoElement) {
  498. const videoEndedHandler = (player as any)._videoEndedHandler;
  499. const timeUpdateHandler = (player as any)._timeUpdateHandler;
  500. if (videoEndedHandler) {
  501. videoElement.removeEventListener('ended', videoEndedHandler);
  502. (player as any)._videoEndedHandler = null;
  503. }
  504. if (timeUpdateHandler) {
  505. videoElement.removeEventListener('timeupdate', timeUpdateHandler);
  506. (player as any)._timeUpdateHandler = null;
  507. }
  508. }
  509. }
  510. };
  511. }
  512. }, [viewState, navigate]);
  513. // 监听播放结束事件(备用方案,确保事件已绑定)
  514. useEffect(() => {
  515. if (!playerRef.current) return;
  516. const player = playerRef.current;
  517. // 如果还没有绑定 ended 事件,则绑定
  518. if (!(player as any)._endedHandler) {
  519. const handleEnded = () => {
  520. console.log('[WelcomePage] 视频播放完成,跳转到 /naming');
  521. navigate('/naming');
  522. };
  523. player.on('ended', handleEnded);
  524. (player as any)._endedHandler = handleEnded;
  525. }
  526. }, [navigate, viewState]); // 当播放状态改变时,确保事件已绑定
  527. return (
  528. <>
  529. {/* Video.js 全局样式 */}
  530. <style>{`
  531. .video-js .vjs-control-bar {
  532. display: none !important;
  533. opacity: 0 !important;
  534. visibility: hidden !important;
  535. }
  536. .video-js:hover .vjs-control-bar,
  537. .video-js.vjs-user-active .vjs-control-bar,
  538. .video-js.vjs-user-inactive .vjs-control-bar,
  539. .video-js.vjs-paused .vjs-control-bar {
  540. display: none !important;
  541. opacity: 0 !important;
  542. visibility: hidden !important;
  543. }
  544. .video-js video::-webkit-media-controls {
  545. display: none !important;
  546. -webkit-appearance: none !important;
  547. opacity: 0 !important;
  548. visibility: hidden !important;
  549. }
  550. .video-js video::-webkit-media-controls-enclosure {
  551. display: none !important;
  552. -webkit-appearance: none !important;
  553. opacity: 0 !important;
  554. visibility: hidden !important;
  555. }
  556. .video-js video::-webkit-media-controls-panel,
  557. .video-js video::-webkit-media-controls-play-button,
  558. .video-js video::-webkit-media-controls-timeline,
  559. .video-js video::-webkit-media-controls-current-time-display,
  560. .video-js video::-webkit-media-controls-time-remaining-display,
  561. .video-js video::-webkit-media-controls-mute-button,
  562. .video-js video::-webkit-media-controls-volume-slider,
  563. .video-js video::-webkit-media-controls-fullscreen-button {
  564. display: none !important;
  565. }
  566. video::-webkit-media-controls-start-playback-button,
  567. video::-webkit-media-controls-overlay-enclosure,
  568. video::-webkit-media-controls-overlay-play-button {
  569. display: none !important;
  570. -webkit-appearance: none !important;
  571. opacity: 0 !important;
  572. }
  573. .player-container video,
  574. .video-js video {
  575. -webkit-appearance: none !important;
  576. appearance: none !important;
  577. }
  578. .video-js *:focus {
  579. outline: none;
  580. }
  581. .video-js .vjs-big-play-button {
  582. display: none !important;
  583. opacity: 0 !important;
  584. visibility: hidden !important;
  585. pointer-events: none !important;
  586. }
  587. .video-js .vjs-loading-spinner {
  588. display: none !important;
  589. opacity: 0 !important;
  590. visibility: hidden !important;
  591. pointer-events: none !important;
  592. }
  593. .video-js,
  594. .video-js .vjs-tech,
  595. .video-js video {
  596. pointer-events: auto !important;
  597. cursor: default;
  598. }
  599. .vjs-fluid {
  600. padding-top: 0 !important;
  601. }
  602. .vjs-16-9 {
  603. padding-top: 0 !important;
  604. }
  605. .video-js video {
  606. width: 100% !important;
  607. height: 100% !important;
  608. object-fit: contain;
  609. z-index: 2;
  610. display: block !important;
  611. }
  612. .player-container,
  613. .video-js {
  614. user-select: none;
  615. -webkit-user-select: none;
  616. -moz-user-select: none;
  617. -ms-user-select: none;
  618. -webkit-touch-callout: none;
  619. }
  620. @keyframes spin {
  621. 0% { transform: rotate(0deg); }
  622. 100% { transform: rotate(360deg); }
  623. }
  624. @keyframes pulse {
  625. 0%, 100% { opacity: 1; }
  626. 50% { opacity: 0.6; }
  627. }
  628. `}</style>
  629. <div
  630. style={{
  631. position: 'relative',
  632. width: '100%',
  633. height: 'calc(var(--vh, 1vh) * 100)',
  634. overflow: 'hidden',
  635. paddingTop: 'env(safe-area-inset-top)',
  636. paddingLeft: 'env(safe-area-inset-left)',
  637. paddingRight: 'env(safe-area-inset-right)',
  638. paddingBottom: 'env(safe-area-inset-bottom)',
  639. } as React.CSSProperties & { height: string }}
  640. >
  641. {/* 欢迎界面内容 */}
  642. {viewState === 'welcome' && (
  643. <div
  644. style={{
  645. position: 'relative',
  646. width: '100%',
  647. height: 'calc(var(--vh, 1vh) * 100)',
  648. margin: '0 auto',
  649. zIndex: 1,
  650. } as React.CSSProperties & { height: string }}
  651. >
  652. {/* Lumi-image 图片 - 右margin 44 */}
  653. <img
  654. src={lumiImage}
  655. alt="Lumi"
  656. style={{
  657. position: 'absolute',
  658. top: px2vmin(185),
  659. left: px2vmin(-37),
  660. width: px2vmin(423),
  661. height: px2vmin(423),
  662. objectFit: 'contain',
  663. transform: 'translateZ(0)',
  664. willChange: 'transform',
  665. imageRendering: '-webkit-optimize-contrast',
  666. WebkitUserDrag: 'none',
  667. } as React.CSSProperties & { WebkitUserDrag: string }}
  668. />
  669. {/* Hello 文字 */}
  670. <div
  671. style={{
  672. position: 'absolute',
  673. top: px2vmin(138),
  674. left: '50%',
  675. transform: `translateX(calc(-50% - ${px2vmin(54)})) translateZ(0)`,
  676. width: '100%',
  677. height: px2vmin(113),
  678. background: 'linear-gradient(97.3deg, rgba(245, 243, 240, 0.8) 2.21%, rgba(245, 243, 240, 0.3) 150.89%)',
  679. WebkitBackgroundClip: 'text',
  680. WebkitTextFillColor: 'transparent',
  681. backgroundClip: 'text',
  682. opacity: 1,
  683. display: 'flex',
  684. alignItems: 'center',
  685. justifyContent: 'center',
  686. fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif',
  687. fontWeight: 500,
  688. fontStyle: 'normal',
  689. fontSize: px2vmin(96),
  690. lineHeight: 1,
  691. letterSpacing: 0,
  692. textAlign: 'center',
  693. textTransform: 'capitalize',
  694. userSelect: 'none',
  695. willChange: 'transform',
  696. }}
  697. >
  698. Hello,
  699. </div>
  700. {/* 已有账号图片按钮 - 跳转到 /login */}
  701. <img
  702. src={getOssUrl('have-an-account.png')}
  703. alt="已有账号"
  704. style={{
  705. position: 'absolute',
  706. top: px2vmin(636),
  707. left: '50%',
  708. transform: 'translateX(-50%)',
  709. width: px2vmin(279),
  710. height: 'auto',
  711. cursor: 'pointer',
  712. userSelect: 'none',
  713. WebkitTapHighlightColor: 'transparent',
  714. transition: 'opacity 0.2s ease',
  715. zIndex: 2,
  716. objectFit: 'contain',
  717. }}
  718. onClick={() => navigate('/login')}
  719. onMouseEnter={(e) => { e.currentTarget.style.opacity = '0.8'; }}
  720. onMouseLeave={(e) => { e.currentTarget.style.opacity = '1'; }}
  721. onMouseDown={(e) => { e.currentTarget.style.opacity = '0.8'; }}
  722. onMouseUp={(e) => { e.currentTarget.style.opacity = '1'; }}
  723. role="button"
  724. tabIndex={0}
  725. onKeyDown={(event) => {
  726. if (event.key === 'Enter' || event.key === ' ') {
  727. event.preventDefault();
  728. navigate('/login');
  729. }
  730. }}
  731. />
  732. {/* 首次访问图片按钮 - 跳转到 /naming */}
  733. <img
  734. src={getOssUrl('fitst-time-here.png')}
  735. alt="首次访问"
  736. style={{
  737. position: 'absolute',
  738. top: px2vmin(568),
  739. left: '50%',
  740. transform: 'translateX(-50%)',
  741. width: px2vmin(279),
  742. height: 'auto',
  743. cursor: 'pointer',
  744. userSelect: 'none',
  745. WebkitTapHighlightColor: 'transparent',
  746. transition: 'opacity 0.2s ease',
  747. zIndex: 2,
  748. objectFit: 'contain',
  749. }}
  750. onClick={() => navigate('/naming')}
  751. onMouseEnter={(e) => { e.currentTarget.style.opacity = '0.8'; }}
  752. onMouseLeave={(e) => { e.currentTarget.style.opacity = '1'; }}
  753. onMouseDown={(e) => { e.currentTarget.style.opacity = '0.8'; }}
  754. onMouseUp={(e) => { e.currentTarget.style.opacity = '1'; }}
  755. role="button"
  756. tabIndex={0}
  757. onKeyDown={(event) => {
  758. if (event.key === 'Enter' || event.key === ' ') {
  759. event.preventDefault();
  760. navigate('/naming');
  761. }
  762. }}
  763. />
  764. {/* 协议容器 */}
  765. <div
  766. style={{
  767. position: 'absolute',
  768. left: '50%',
  769. transform: 'translateX(-50%)',
  770. top: px2vmin(708),
  771. display: 'flex',
  772. justifyContent: 'center',
  773. alignItems: 'center',
  774. padding: 0,
  775. zIndex: 2,
  776. }}
  777. >
  778. <div
  779. style={{
  780. display: 'flex',
  781. alignItems: 'center',
  782. gap: '8PX',
  783. }}
  784. >
  785. <div
  786. style={{
  787. width: '16PX',
  788. height: '16PX',
  789. minWidth: '16PX',
  790. borderRadius: '50%',
  791. border: '1.5PX solid rgba(245, 243, 240, 1)',
  792. background: agreementChecked ? 'rgb(145 89 240)' : 'transparent',
  793. borderColor: agreementChecked ? 'rgb(145 89 240)' : 'rgba(245, 243, 240, 1)',
  794. cursor: 'pointer',
  795. transition: 'all 0.2s ease',
  796. position: 'relative',
  797. }}
  798. onClick={() => setAgreementChecked(!agreementChecked)}
  799. >
  800. {agreementChecked && (
  801. <div
  802. style={{
  803. position: 'absolute',
  804. top: '50%',
  805. left: '50%',
  806. transform: 'translate(-50%, -50%)',
  807. width: '6PX',
  808. height: '6PX',
  809. background: 'white',
  810. borderRadius: '50%',
  811. }}
  812. />
  813. )}
  814. </div>
  815. <div
  816. style={{
  817. fontSize: '12PX',
  818. lineHeight: 1.5,
  819. color: 'rgba(245, 243, 240, 1)',
  820. userSelect: 'none',
  821. whiteSpace: 'pre',
  822. }}
  823. >
  824. 我已阅读并同意
  825. <span
  826. style={{
  827. color: 'rgba(191, 157, 248, 1)',
  828. textDecoration: 'none',
  829. cursor: 'pointer',
  830. transition: 'opacity 0.2s ease',
  831. }}
  832. onClick={() => window.open('/user-agreement.html', '_blank')}
  833. onMouseEnter={(e) => { e.currentTarget.style.opacity = '0.8'; }}
  834. onMouseLeave={(e) => { e.currentTarget.style.opacity = '1'; }}
  835. >
  836. 《使用协议》
  837. </span>
  838. 和
  839. <span
  840. style={{
  841. color: 'rgba(191, 157, 248, 1)',
  842. textDecoration: 'none',
  843. cursor: 'pointer',
  844. transition: 'opacity 0.2s ease',
  845. }}
  846. onClick={() => window.open('/privacy-policy.html', '_blank')}
  847. onMouseEnter={(e) => { e.currentTarget.style.opacity = '0.8'; }}
  848. onMouseLeave={(e) => { e.currentTarget.style.opacity = '1'; }}
  849. >
  850. 《隐私政策》
  851. </span>
  852. </div>
  853. </div>
  854. </div>
  855. </div>
  856. )}
  857. {/* 加载动画遮罩 */}
  858. {isLoading && (
  859. <div
  860. style={{
  861. position: 'fixed',
  862. top: 0,
  863. left: 0,
  864. width: '100%',
  865. height: '100%',
  866. background: 'rgba(0, 0, 0, 0.7)',
  867. display: 'flex',
  868. justifyContent: 'center',
  869. alignItems: 'center',
  870. zIndex: 10000,
  871. backdropFilter: 'blur(4px)',
  872. }}
  873. >
  874. <div
  875. style={{
  876. display: 'flex',
  877. flexDirection: 'column',
  878. alignItems: 'center',
  879. gap: px2vmin(24),
  880. }}
  881. >
  882. {/* 加载动画 */}
  883. <div
  884. style={{
  885. position: 'relative',
  886. width: px2vmin(80),
  887. height: px2vmin(80),
  888. }}
  889. >
  890. <div
  891. style={{
  892. position: 'absolute',
  893. top: 0,
  894. left: 0,
  895. width: '100%',
  896. height: '100%',
  897. border: `${px2vmin(3)} solid transparent`,
  898. borderTopColor: 'rgba(255, 255, 255, 0.9)',
  899. borderRadius: '50%',
  900. animation: 'spin 1s linear infinite',
  901. }}
  902. />
  903. <div
  904. style={{
  905. position: 'absolute',
  906. top: '15%',
  907. left: '15%',
  908. width: '70%',
  909. height: '70%',
  910. border: `${px2vmin(3)} solid transparent`,
  911. borderTopColor: 'rgba(255, 255, 255, 0.7)',
  912. borderRadius: '50%',
  913. animation: 'spin 1.5s linear infinite reverse',
  914. }}
  915. />
  916. <div
  917. style={{
  918. position: 'absolute',
  919. top: '30%',
  920. left: '30%',
  921. width: '40%',
  922. height: '40%',
  923. border: `${px2vmin(3)} solid transparent`,
  924. borderTopColor: 'rgba(255, 255, 255, 0.5)',
  925. borderRadius: '50%',
  926. animation: 'spin 2s linear infinite',
  927. }}
  928. />
  929. </div>
  930. {/* 加载文本 */}
  931. <p
  932. style={{
  933. color: '#fff',
  934. fontWeight: 500,
  935. fontSize: px2vmin(16),
  936. textShadow: `0 ${px2vmin(2)} ${px2vmin(4)} rgba(0, 0, 0, 0.3)`,
  937. animation: 'pulse 1.5s ease-in-out infinite',
  938. }}
  939. >
  940. 视频加载中...
  941. </p>
  942. </div>
  943. </div>
  944. )}
  945. {/* 视频播放器容器 */}
  946. <div
  947. style={{
  948. position: 'fixed',
  949. top: 0,
  950. left: 0,
  951. width: '100%',
  952. height: '100%',
  953. zIndex: 10,
  954. transition: 'opacity 0.3s ease-in',
  955. opacity: viewState === 'playing' ? 1 : 0,
  956. visibility: viewState === 'playing' ? 'visible' : 'hidden',
  957. pointerEvents: viewState === 'playing' ? 'auto' : 'none',
  958. }}
  959. >
  960. <video
  961. ref={videoRef}
  962. className="video-js"
  963. playsInline
  964. webkit-playsinline="true"
  965. x5-playsinline="true"
  966. x5-video-player-type="h5"
  967. x5-video-player-fullscreen="false"
  968. data-setup="{}"
  969. controlsList="nodownload nofullscreen noremoteplayback"
  970. disablePictureInPicture
  971. preload="auto"
  972. style={{
  973. position: 'absolute',
  974. top: 0,
  975. left: 0,
  976. width: '100%',
  977. height: '100%',
  978. paddingTop: 0,
  979. background: 'transparent',
  980. zIndex: 1,
  981. }}
  982. >
  983. <source
  984. src={import.meta.env.VITE_WELCOME_VIDEO_URL || getOssUrl('Lyrria-video.mp4')}
  985. type="video/mp4"
  986. />
  987. <p className="vjs-no-js">
  988. 您的浏览器不支持HTML5视频,请升级浏览器。
  989. </p>
  990. </video>
  991. </div>
  992. </div>
  993. </>
  994. );
  995. };