WelcomePage.tsx 35 KB

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