VideoJSPlayer.vue 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900
  1. <template>
  2. <div class="video-js-container">
  3. <!-- 封面图片 - 只在没有视频源时显示 -->
  4. <img
  5. v-if="!hasVideoSource && processedCoverUrl"
  6. :src="processedCoverUrl"
  7. :alt="alt"
  8. :class="coverClass"
  9. @error="handleCoverError"
  10. @load="handleCoverLoad"
  11. style="
  12. width: 100% !important;
  13. height: 100% !important;
  14. opacity: 1 !important;
  15. "
  16. />
  17. <!-- Video.js 播放器容器 - 只在有视频源时显示 -->
  18. <div
  19. v-if="hasVideoSource"
  20. ref="videoContainer"
  21. class="video-js-wrapper"
  22. :class="[videoClass, { 'loading-video': loading }]"
  23. ></div>
  24. <!-- 加载状态 -->
  25. <div v-if="loading" class="loading-overlay">
  26. <div class="spinner"></div>
  27. <span class="loading-text">加载中...</span>
  28. </div>
  29. <!-- 错误状态 -->
  30. <div v-if="error && !hideError" class="error-overlay">
  31. <div class="error-content">
  32. <div class="error-icon">⚠️</div>
  33. <div class="error-text">{{ error }}</div>
  34. <button v-if="showRetryButton" @click="retry" class="retry-btn">
  35. 重试
  36. </button>
  37. </div>
  38. </div>
  39. </div>
  40. </template>
  41. <script setup lang="ts">
  42. import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
  43. import videojs from "video.js";
  44. import "video.js/dist/video-js.css";
  45. // Props
  46. interface Props {
  47. coverUrl?: string;
  48. m3u8Url?: string;
  49. alt?: string;
  50. coverClass?: string;
  51. videoClass?: string;
  52. autoPlay?: boolean;
  53. hideError?: boolean;
  54. enableRetry?: boolean;
  55. }
  56. const props = withDefaults(defineProps<Props>(), {
  57. coverUrl: "",
  58. m3u8Url: "",
  59. alt: "Video Cover",
  60. coverClass: "w-full h-full object-cover",
  61. videoClass: "w-full h-full object-contain",
  62. autoPlay: false,
  63. hideError: false,
  64. enableRetry: true,
  65. });
  66. // Emits
  67. const emit = defineEmits<{
  68. coverLoaded: [url: string];
  69. videoLoaded: [url: string];
  70. error: [error: string];
  71. retry: [];
  72. play: [];
  73. timeupdate: [];
  74. seeking: [];
  75. canplay: [];
  76. }>();
  77. // 响应式数据
  78. const processedCoverUrl = ref<string>("");
  79. const processedVideoUrl = ref<string>("");
  80. const loading = ref(false);
  81. const error = ref("");
  82. const retryCount = ref(0);
  83. const maxRetries = 3;
  84. const videoContainer = ref<HTMLDivElement>();
  85. const player = ref<any>(null);
  86. // 计算属性
  87. const hasVideoSource = computed(
  88. () => !!props.m3u8Url && props.m3u8Url.trim() !== ""
  89. );
  90. const showRetryButton = computed(
  91. () => props.enableRetry && retryCount.value < maxRetries
  92. );
  93. // 核心解密函数(仅用于封面)
  94. const loader = async (url: string): Promise<Blob | string> => {
  95. const response = await fetch(url, { mode: "cors" });
  96. if (!response.ok) {
  97. throw new Error(`HTTP error! status: ${response.status}`);
  98. }
  99. const reader = response.body!.getReader();
  100. const key = new Uint8Array(8);
  101. const buffer: Uint8Array[] = [];
  102. let len = 0;
  103. let offset = 0;
  104. try {
  105. for (;;) {
  106. const read = await reader.read();
  107. if (read.done) break;
  108. if (!read.value) continue;
  109. if (len < 8) {
  110. let i = 0;
  111. while (i < read.value.length) {
  112. key[len++] = read.value[i++];
  113. if (len > 7) break;
  114. }
  115. if (len < 8) continue;
  116. read.value = read.value.slice(i);
  117. }
  118. // 复制数据以避免修改原始数据
  119. const decryptedValue = new Uint8Array(read.value.length);
  120. for (let i = 0; i < read.value.length; ++i) {
  121. decryptedValue[i] = read.value[i] ^ key[offset++ % 8];
  122. }
  123. buffer.push(decryptedValue);
  124. }
  125. // 合并所有缓冲区
  126. const totalLength = buffer.reduce((sum, arr) => sum + arr.length, 0);
  127. const result = new Uint8Array(totalLength);
  128. let position = 0;
  129. for (const arr of buffer) {
  130. result.set(arr, position);
  131. position += arr.length;
  132. }
  133. const isPoster = url.includes("cover");
  134. const type = isPoster ? "application/octet-stream" : "text/plain";
  135. const blob = new Blob([result], { type: type });
  136. return isPoster ? blob : await blob.text();
  137. } finally {
  138. reader.releaseLock();
  139. }
  140. };
  141. // 处理封面 URL
  142. const processCover = async (url: string): Promise<void> => {
  143. if (!url) return;
  144. try {
  145. loading.value = true;
  146. error.value = "";
  147. // 检查是否需要解密(仅对封面进行解密)
  148. if (url.includes("cover")) {
  149. try {
  150. const decryptedData = await loader(url);
  151. if (decryptedData instanceof Blob) {
  152. // 直接使用解密后的 Blob 创建 URL
  153. const blobUrl = URL.createObjectURL(decryptedData);
  154. processedCoverUrl.value = blobUrl;
  155. } else {
  156. processedCoverUrl.value = decryptedData;
  157. }
  158. } catch (decryptErr) {
  159. console.error("封面解密失败,使用原始URL:", decryptErr);
  160. processedCoverUrl.value = url;
  161. }
  162. } else {
  163. console.log("使用原始封面URL");
  164. processedCoverUrl.value = url;
  165. }
  166. // 预加载图片以确保它可以正确显示
  167. const img = new Image();
  168. img.onload = () => {
  169. emit("coverLoaded", processedCoverUrl.value);
  170. };
  171. img.onerror = () => {
  172. console.error("封面图片加载失败");
  173. // 如果加载失败,尝试使用原始URL
  174. if (processedCoverUrl.value !== url) {
  175. processedCoverUrl.value = url;
  176. emit("coverLoaded", url);
  177. }
  178. };
  179. img.src = processedCoverUrl.value;
  180. } catch (err) {
  181. console.error("处理封面失败:", err);
  182. // 封面处理失败时使用原始 URL
  183. processedCoverUrl.value = url;
  184. emit("coverLoaded", processedCoverUrl.value);
  185. } finally {
  186. loading.value = false;
  187. }
  188. };
  189. // 处理视频 URL - 直接使用
  190. const processVideo = async (url: string): Promise<void> => {
  191. if (!url) return;
  192. processedVideoUrl.value = url;
  193. await nextTick();
  194. await initVideoJSPlayer();
  195. emit("videoLoaded", url);
  196. };
  197. // 初始化 Video.js 播放器
  198. const initVideoJSPlayer = async (): Promise<void> => {
  199. return new Promise(async (resolve, reject) => {
  200. if (!videoContainer.value || !processedVideoUrl.value) {
  201. console.log("初始化失败:缺少容器或视频URL");
  202. reject(new Error("缺少容器或视频URL"));
  203. return;
  204. }
  205. // 确保容器是干净的
  206. if (videoContainer.value) {
  207. videoContainer.value.innerHTML = "";
  208. }
  209. // 处理封面 URL
  210. let posterUrl = "";
  211. if (props.coverUrl) {
  212. await processCover(props.coverUrl);
  213. posterUrl = processedCoverUrl.value;
  214. }
  215. // 创建 video 元素
  216. const videoElement = document.createElement("video");
  217. videoElement.className = "video-js vjs-big-play-centered";
  218. videoElement.controls = true;
  219. videoElement.preload = "auto";
  220. // iOS 特殊配置,防止自动全屏
  221. videoElement.setAttribute("playsinline", "true");
  222. videoElement.setAttribute("webkit-playsinline", "true");
  223. videoElement.setAttribute("x-webkit-airplay", "allow");
  224. // 设置封面
  225. if (posterUrl) {
  226. videoElement.poster = posterUrl;
  227. }
  228. // 添加到容器
  229. videoContainer.value.appendChild(videoElement);
  230. const options = {
  231. controls: true,
  232. responsive: true,
  233. fluid: true,
  234. preload: "auto",
  235. techOrder: ["html5"],
  236. bigPlayButton: true, // 启用大型播放按钮
  237. // iOS 特殊配置
  238. playsinline: true,
  239. webkitPlaysinline: true,
  240. userActions: {
  241. // 保持默认的点击行为,让控制栏可以正常显示/隐藏
  242. click: true,
  243. doubleClick: true,
  244. hotkeys: true,
  245. },
  246. controlBar: {
  247. // 自定义控制栏
  248. children: [
  249. "playToggle", // 播放/暂停按钮
  250. "forward10Button", // 快进 10 秒按钮
  251. "forwardButton", // 快进按钮
  252. "currentTimeDisplay", // 当前时间
  253. "timeDivider", // 时间分隔符
  254. "durationDisplay", // 总时长
  255. "progressControl", // 进度条
  256. "volumePanel", // 音量控制
  257. "fullscreenToggle", // 全屏按钮
  258. ],
  259. // 确保全屏按钮在右侧
  260. fullscreenToggle: {
  261. index: 9,
  262. },
  263. },
  264. // 使用默认的 HTML5 配置,Video.js 内置 HLS 支持
  265. sources: processedVideoUrl.value
  266. ? [
  267. {
  268. src: processedVideoUrl.value,
  269. type: "application/x-mpegURL",
  270. },
  271. ]
  272. : [],
  273. poster: posterUrl, // 使用处理后的封面URL
  274. };
  275. try {
  276. // 注册快进按钮组件
  277. const registerSeekButtons = () => {
  278. try {
  279. // 注册快进按钮
  280. const ForwardButton = videojs.getComponent("Button");
  281. class ForwardButtonComponent extends ForwardButton {
  282. constructor(player: any, options: any) {
  283. super(player, options);
  284. (this as any).controlText("快进10秒");
  285. }
  286. handleClick() {
  287. const time = (this as any).player().currentTime();
  288. const duration = (this as any).player().duration();
  289. if (typeof time === "number" && typeof duration === "number") {
  290. (this as any)
  291. .player()
  292. .currentTime(Math.min(duration, time + 10));
  293. }
  294. }
  295. buildCSSClass() {
  296. return `vjs-forward-button ${super.buildCSSClass()}`;
  297. }
  298. }
  299. videojs.registerComponent("ForwardButton", ForwardButtonComponent);
  300. // 注册快进 10 秒按钮
  301. const Forward10Button = videojs.getComponent("Button");
  302. class Forward10ButtonComponent extends Forward10Button {
  303. constructor(player: any, options: any) {
  304. super(player, options);
  305. (this as any).controlText("快进10秒");
  306. }
  307. handleClick() {
  308. const time = (this as any).player().currentTime();
  309. const duration = (this as any).player().duration();
  310. if (typeof time === "number" && typeof duration === "number") {
  311. (this as any)
  312. .player()
  313. .currentTime(Math.min(duration, time + 10));
  314. }
  315. }
  316. buildCSSClass() {
  317. return `vjs-forward-10-button ${super.buildCSSClass()}`;
  318. }
  319. }
  320. videojs.registerComponent(
  321. "Forward10Button",
  322. Forward10ButtonComponent
  323. );
  324. } catch (err) {
  325. console.error("注册快进快退按钮失败:", err);
  326. }
  327. };
  328. registerSeekButtons();
  329. player.value = videojs(videoElement, options);
  330. // 自定义点击行为
  331. player.value.ready(() => {
  332. error.value = "";
  333. loading.value = false;
  334. emit("canplay");
  335. // 使用 Video.js 默认的点击行为,不需要自定义处理
  336. // 确保 iOS 设备上的内联播放设置
  337. const videoEl = player.value.el().querySelector("video");
  338. if (videoEl) {
  339. videoEl.setAttribute("playsinline", "true");
  340. videoEl.setAttribute("webkit-playsinline", "true");
  341. videoEl.setAttribute("x-webkit-airplay", "allow");
  342. }
  343. // 监听播放和暂停事件,动态更新 poster
  344. player.value.on("play", () => {
  345. // 播放时隐藏 poster
  346. if (player.value.poster()) {
  347. player.value.poster("");
  348. }
  349. });
  350. player.value.on("pause", () => {
  351. // 暂停时显示当前帧作为 poster
  352. const videoElement = player.value.el().querySelector("video");
  353. if (videoElement) {
  354. // 创建 canvas 来捕获当前帧
  355. const canvas = document.createElement("canvas");
  356. const ctx = canvas.getContext("2d");
  357. if (ctx) {
  358. canvas.width = videoElement.videoWidth;
  359. canvas.height = videoElement.videoHeight;
  360. ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
  361. // 将 canvas 转换为 blob URL
  362. canvas.toBlob(
  363. (blob) => {
  364. if (blob) {
  365. const currentFrameUrl = URL.createObjectURL(blob);
  366. player.value.poster(currentFrameUrl);
  367. // 清理之前的 blob URL
  368. const oldPoster = player.value.poster();
  369. if (
  370. oldPoster &&
  371. oldPoster.startsWith("blob:") &&
  372. oldPoster !== currentFrameUrl
  373. ) {
  374. URL.revokeObjectURL(oldPoster);
  375. }
  376. }
  377. },
  378. "image/jpeg",
  379. 0.8
  380. );
  381. }
  382. }
  383. });
  384. resolve();
  385. });
  386. player.value.on("loadstart", () => {
  387. loading.value = true;
  388. });
  389. player.value.on("loadeddata", () => {
  390. loading.value = false;
  391. });
  392. player.value.on("canplay", () => {
  393. loading.value = false;
  394. error.value = "";
  395. emit("canplay");
  396. });
  397. player.value.on("play", () => {
  398. emit("play");
  399. });
  400. player.value.on("timeupdate", () => {
  401. emit("timeupdate");
  402. });
  403. player.value.on("seeking", () => {
  404. emit("seeking");
  405. });
  406. player.value.on("error", (event: any) => {
  407. console.error("Video.js 播放错误:", event);
  408. error.value = "视频播放失败";
  409. loading.value = false;
  410. emit("error", error.value);
  411. reject(event);
  412. });
  413. } catch (err) {
  414. console.error("Video.js 初始化失败:", err);
  415. error.value = "播放器初始化失败";
  416. loading.value = false;
  417. emit("error", error.value);
  418. reject(err);
  419. }
  420. });
  421. };
  422. // 销毁播放器
  423. const destroyPlayer = (): void => {
  424. if (player.value) {
  425. player.value.dispose();
  426. player.value = null;
  427. }
  428. };
  429. // 重试功能
  430. const retry = (): void => {
  431. if (retryCount.value >= maxRetries) return;
  432. retryCount.value++;
  433. error.value = "";
  434. emit("retry");
  435. destroyPlayer();
  436. if (props.coverUrl) processCover(props.coverUrl);
  437. if (props.m3u8Url) processVideo(props.m3u8Url);
  438. };
  439. // 停止视频播放
  440. const stopVideo = (): void => {
  441. if (player.value) {
  442. player.value.pause();
  443. player.value.currentTime(0);
  444. }
  445. destroyPlayer();
  446. };
  447. // 事件处理
  448. const handleCoverError = (event: Event): void => {
  449. const img = event.target as HTMLImageElement;
  450. if (img.src.startsWith("blob:")) {
  451. processedCoverUrl.value = props.coverUrl;
  452. }
  453. };
  454. const handleCoverLoad = (): void => {
  455. // 确保图片可见
  456. if (!hasVideoSource.value) {
  457. const img = document.querySelector(
  458. ".video-js-container img"
  459. ) as HTMLImageElement;
  460. if (img) {
  461. img.style.display = "block";
  462. img.style.opacity = "1";
  463. }
  464. }
  465. };
  466. // 监听 props 变化
  467. watch(
  468. () => props.coverUrl,
  469. (newUrl, oldUrl) => {
  470. if (newUrl && newUrl !== oldUrl) {
  471. if (processedCoverUrl.value?.startsWith("blob:")) {
  472. URL.revokeObjectURL(processedCoverUrl.value);
  473. processedCoverUrl.value = "";
  474. }
  475. processCover(newUrl);
  476. } else if (newUrl) {
  477. processCover(newUrl);
  478. }
  479. },
  480. { immediate: true }
  481. );
  482. watch(
  483. () => props.m3u8Url,
  484. (newUrl, oldUrl) => {
  485. if (oldUrl && newUrl !== oldUrl) {
  486. console.log("视频URL变化,停止当前播放并加载新视频", { newUrl, oldUrl });
  487. stopVideo();
  488. if (newUrl) {
  489. processVideo(newUrl);
  490. }
  491. } else if (newUrl) {
  492. processVideo(newUrl);
  493. }
  494. },
  495. { immediate: true }
  496. );
  497. // 组件卸载时清理资源
  498. onUnmounted(() => {
  499. // 清理播放器的 poster blob URL
  500. if (player.value && player.value.poster()) {
  501. const posterUrl = player.value.poster();
  502. if (posterUrl.startsWith("blob:")) {
  503. URL.revokeObjectURL(posterUrl);
  504. }
  505. }
  506. destroyPlayer();
  507. // 清理 Blob URL
  508. if (processedCoverUrl.value?.startsWith("blob:")) {
  509. URL.revokeObjectURL(processedCoverUrl.value);
  510. }
  511. });
  512. // 手动播放方法
  513. const playVideo = (): void => {
  514. console.log("手动播放被调用");
  515. if (player.value) {
  516. player.value.play().catch((err: any) => {
  517. console.error("播放失败:", err);
  518. });
  519. }
  520. };
  521. // 暂停播放方法
  522. const pauseVideo = (): void => {
  523. player.value?.pause();
  524. };
  525. // 暴露方法给父组件
  526. defineExpose({
  527. retry,
  528. stopVideo,
  529. playVideo,
  530. pauseVideo,
  531. processedVideoUrl: computed(() => processedVideoUrl.value),
  532. loading: computed(() => loading.value),
  533. error: computed(() => error.value),
  534. });
  535. </script>
  536. <style scoped>
  537. .video-js-container {
  538. position: relative;
  539. width: 100%;
  540. height: 100%;
  541. background-color: #000;
  542. overflow: hidden;
  543. }
  544. .video-js-container img {
  545. width: 100%;
  546. height: 100%;
  547. object-fit: cover;
  548. opacity: 1;
  549. }
  550. .video-js-wrapper {
  551. width: 100%;
  552. height: 100%;
  553. }
  554. /* 加载中时的视频容器样式 */
  555. .loading-video :deep(.vjs-big-play-button) {
  556. display: none !important;
  557. }
  558. /* Video.js 自定义样式 */
  559. :deep(.video-js) {
  560. width: 100%;
  561. height: 100%;
  562. background-color: #000;
  563. }
  564. /* Video.js poster 样式 */
  565. :deep(.video-js .vjs-poster) {
  566. background-size: cover;
  567. opacity: 1 !important;
  568. background-position: center center;
  569. background-repeat: no-repeat;
  570. z-index: 1;
  571. }
  572. /* 播放时隐藏 poster */
  573. :deep(.video-js.vjs-playing .vjs-poster) {
  574. display: none !important;
  575. }
  576. /* 暂停时显示 poster */
  577. :deep(.video-js.vjs-paused .vjs-poster) {
  578. display: block !important;
  579. opacity: 1 !important;
  580. z-index: 1;
  581. }
  582. /* 自定义中央播放按钮样式 */
  583. :deep(.video-js .vjs-big-play-button) {
  584. font-size: 3em;
  585. line-height: 1.5em;
  586. height: 1.5em;
  587. width: 1.5em;
  588. border: none;
  589. border-radius: 50%;
  590. background-color: rgba(59, 130, 246, 0.8);
  591. box-shadow: 0 0 20px rgba(59, 130, 246, 0.4);
  592. position: absolute;
  593. top: 50%;
  594. left: 50%;
  595. margin-top: -0.75em;
  596. margin-left: -0.75em;
  597. padding: 0;
  598. cursor: pointer;
  599. transition: all 0.3s ease;
  600. z-index: 100;
  601. }
  602. :deep(.video-js:hover .vjs-big-play-button) {
  603. background-color: rgba(59, 130, 246, 1);
  604. transform: scale(1.1);
  605. }
  606. /* 快进 10 秒按钮样式 */
  607. :deep(.video-js .vjs-forward-10-button) {
  608. font-size: 1.3em;
  609. position: relative;
  610. transition: all 0.2s ease;
  611. }
  612. :deep(.video-js .vjs-forward-10-button::before) {
  613. content: "\e9b1"; /* pi-angle-double-right */
  614. font-family: "primeicons", "VideoJS", sans-serif;
  615. text-shadow: 0 0 8px rgba(255, 255, 255, 0.3);
  616. display: inline-block;
  617. line-height: 1;
  618. }
  619. /* 按钮悬停效果 */
  620. :deep(.video-js .vjs-forward-10-button:hover) {
  621. transform: scale(1.1);
  622. color: rgba(255, 255, 255, 0.9);
  623. }
  624. /* 按钮激活效果 */
  625. :deep(.video-js .vjs-forward-10-button:active) {
  626. transform: scale(0.95);
  627. }
  628. /* 确保进度条有足够的显示长度 */
  629. :deep(.video-js .vjs-progress-control) {
  630. flex: 1 !important;
  631. min-width: 0 !important;
  632. }
  633. :deep(.video-js .vjs-progress-holder) {
  634. flex: 1 !important;
  635. width: 100% !important;
  636. }
  637. /* iOS 设备特殊样式 */
  638. @supports (-webkit-touch-callout: none) {
  639. :deep(.video-js video) {
  640. -webkit-playsinline: true;
  641. playsinline: true;
  642. }
  643. :deep(.video-js) {
  644. -webkit-transform: translateZ(0);
  645. transform: translateZ(0);
  646. }
  647. }
  648. .loading-overlay {
  649. position: absolute;
  650. top: 0;
  651. left: 0;
  652. right: 0;
  653. bottom: 0;
  654. display: flex;
  655. flex-direction: column;
  656. align-items: center;
  657. justify-content: center;
  658. background: rgba(0, 0, 0, 0.6);
  659. backdrop-filter: blur(4px);
  660. color: white;
  661. z-index: 10;
  662. border-radius: 8px;
  663. }
  664. .spinner {
  665. width: 40px;
  666. height: 40px;
  667. border: 3px solid rgba(59, 130, 246, 0.2);
  668. border-top: 3px solid rgba(59, 130, 246, 1);
  669. border-radius: 50%;
  670. animation: spin 1s cubic-bezier(0.4, 0, 0.2, 1) infinite;
  671. margin-bottom: 12px;
  672. box-shadow: 0 0 15px rgba(59, 130, 246, 0.3);
  673. }
  674. @keyframes spin {
  675. 0% {
  676. transform: rotate(0deg);
  677. }
  678. 100% {
  679. transform: rotate(360deg);
  680. }
  681. }
  682. .loading-text {
  683. font-size: 16px;
  684. color: white;
  685. font-weight: 500;
  686. text-shadow: 0 0 10px rgba(255, 255, 255, 0.5);
  687. animation: pulse 1.5s ease-in-out infinite;
  688. }
  689. @keyframes pulse {
  690. 0%,
  691. 100% {
  692. opacity: 0.8;
  693. }
  694. 50% {
  695. opacity: 1;
  696. }
  697. }
  698. .error-overlay {
  699. position: absolute;
  700. top: 0;
  701. left: 0;
  702. right: 0;
  703. bottom: 0;
  704. display: flex;
  705. align-items: center;
  706. justify-content: center;
  707. background: rgba(0, 0, 0, 0.75);
  708. backdrop-filter: blur(4px);
  709. z-index: 10;
  710. border-radius: 8px;
  711. }
  712. .error-content {
  713. text-align: center;
  714. color: white;
  715. padding: 24px;
  716. background: rgba(255, 255, 255, 0.1);
  717. border-radius: 12px;
  718. backdrop-filter: blur(8px);
  719. box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
  720. max-width: 300px;
  721. width: 80%;
  722. }
  723. .error-icon {
  724. font-size: 40px;
  725. margin-bottom: 16px;
  726. animation: shake 0.5s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
  727. }
  728. @keyframes shake {
  729. 10%,
  730. 90% {
  731. transform: translate3d(-1px, 0, 0);
  732. }
  733. 20%,
  734. 80% {
  735. transform: translate3d(2px, 0, 0);
  736. }
  737. 30%,
  738. 50%,
  739. 70% {
  740. transform: translate3d(-3px, 0, 0);
  741. }
  742. 40%,
  743. 60% {
  744. transform: translate3d(3px, 0, 0);
  745. }
  746. }
  747. .error-text {
  748. font-size: 16px;
  749. margin-bottom: 20px;
  750. color: #ff6b6b;
  751. line-height: 1.5;
  752. }
  753. .retry-btn {
  754. padding: 10px 20px;
  755. background: linear-gradient(to right, #3b82f6, #60a5fa);
  756. color: white;
  757. border: none;
  758. border-radius: 8px;
  759. font-size: 15px;
  760. font-weight: 600;
  761. cursor: pointer;
  762. transition: all 0.3s ease;
  763. box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
  764. }
  765. .retry-btn:hover {
  766. background: linear-gradient(to right, #2563eb, #3b82f6);
  767. transform: translateY(-2px);
  768. box-shadow: 0 6px 16px rgba(59, 130, 246, 0.5);
  769. }
  770. /* 移动端优化 */
  771. @media screen and (max-width: 768px) {
  772. :deep(.video-js .vjs-big-play-button) {
  773. font-size: 2.5em;
  774. height: 1.5em;
  775. width: 1.5em;
  776. margin-top: -0.75em;
  777. margin-left: -0.75em;
  778. }
  779. .error-content {
  780. padding: 18px;
  781. max-width: 260px;
  782. }
  783. .error-icon {
  784. font-size: 32px;
  785. }
  786. .error-text {
  787. font-size: 14px;
  788. }
  789. .retry-btn {
  790. padding: 8px 16px;
  791. font-size: 14px;
  792. }
  793. }
  794. </style>