VideoJSPlayer.vue 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908
  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. "replayButton", // 回退按钮
  252. "forwardButton", // 快进按钮
  253. "currentTimeDisplay", // 当前时间
  254. "timeDivider", // 时间分隔符
  255. "durationDisplay", // 总时长
  256. "progressControl", // 进度条
  257. "volumePanel", // 音量控制
  258. "fullscreenToggle", // 全屏按钮
  259. ],
  260. // 确保全屏按钮在右侧
  261. fullscreenToggle: {
  262. index: 9,
  263. },
  264. },
  265. // 使用默认的 HTML5 配置,Video.js 内置 HLS 支持
  266. sources: processedVideoUrl.value
  267. ? [
  268. {
  269. src: processedVideoUrl.value,
  270. type: "application/x-mpegURL",
  271. },
  272. ]
  273. : [],
  274. poster: posterUrl, // 使用处理后的封面URL
  275. };
  276. try {
  277. // 注册快进快退按钮组件
  278. const registerSeekButtons = () => {
  279. try {
  280. // 注册回退按钮
  281. const ReplayButton = videojs.getComponent("Button");
  282. class ReplayButtonComponent extends ReplayButton {
  283. constructor(player: any, options: any) {
  284. super(player, options);
  285. (this as any).controlText("回退10秒");
  286. }
  287. handleClick() {
  288. const time = (this as any).player().currentTime();
  289. if (typeof time === "number") {
  290. (this as any).player().currentTime(Math.max(0, time - 10));
  291. }
  292. }
  293. buildCSSClass() {
  294. return `vjs-replay-button ${super.buildCSSClass()}`;
  295. }
  296. }
  297. videojs.registerComponent("ReplayButton", ReplayButtonComponent);
  298. // 注册快进按钮
  299. const ForwardButton = videojs.getComponent("Button");
  300. class ForwardButtonComponent extends ForwardButton {
  301. constructor(player: any, options: any) {
  302. super(player, options);
  303. (this as any).controlText("快进10秒");
  304. }
  305. handleClick() {
  306. const time = (this as any).player().currentTime();
  307. const duration = (this as any).player().duration();
  308. if (typeof time === "number" && typeof duration === "number") {
  309. (this as any)
  310. .player()
  311. .currentTime(Math.min(duration, time + 10));
  312. }
  313. }
  314. buildCSSClass() {
  315. return `vjs-forward-button ${super.buildCSSClass()}`;
  316. }
  317. }
  318. videojs.registerComponent("ForwardButton", ForwardButtonComponent);
  319. // 注册快进 10 秒按钮
  320. const Forward10Button = videojs.getComponent("Button");
  321. class Forward10ButtonComponent extends Forward10Button {
  322. constructor(player: any, options: any) {
  323. super(player, options);
  324. (this as any).controlText("快进10秒");
  325. }
  326. handleClick() {
  327. const time = (this as any).player().currentTime();
  328. const duration = (this as any).player().duration();
  329. if (typeof time === "number" && typeof duration === "number") {
  330. (this as any)
  331. .player()
  332. .currentTime(Math.min(duration, time + 10));
  333. }
  334. }
  335. buildCSSClass() {
  336. return `vjs-forward-10-button ${super.buildCSSClass()}`;
  337. }
  338. }
  339. videojs.registerComponent(
  340. "Forward10Button",
  341. Forward10ButtonComponent
  342. );
  343. } catch (err) {
  344. console.error("注册快进快退按钮失败:", err);
  345. }
  346. };
  347. registerSeekButtons();
  348. player.value = videojs(videoElement, options);
  349. // 自定义点击行为
  350. player.value.ready(() => {
  351. error.value = "";
  352. loading.value = false;
  353. emit("canplay");
  354. // 使用 Video.js 默认的点击行为,不需要自定义处理
  355. // 确保 iOS 设备上的内联播放设置
  356. const videoEl = player.value.el().querySelector("video");
  357. if (videoEl) {
  358. videoEl.setAttribute("playsinline", "true");
  359. videoEl.setAttribute("webkit-playsinline", "true");
  360. videoEl.setAttribute("x-webkit-airplay", "allow");
  361. }
  362. // 监听播放和暂停事件,动态更新 poster
  363. player.value.on("play", () => {
  364. // 播放时隐藏 poster
  365. if (player.value.poster()) {
  366. player.value.poster("");
  367. }
  368. });
  369. player.value.on("pause", () => {
  370. // 暂停时显示当前帧作为 poster
  371. const videoElement = player.value.el().querySelector("video");
  372. if (videoElement) {
  373. // 创建 canvas 来捕获当前帧
  374. const canvas = document.createElement("canvas");
  375. const ctx = canvas.getContext("2d");
  376. if (ctx) {
  377. canvas.width = videoElement.videoWidth;
  378. canvas.height = videoElement.videoHeight;
  379. ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
  380. // 将 canvas 转换为 blob URL
  381. canvas.toBlob(
  382. (blob) => {
  383. if (blob) {
  384. const currentFrameUrl = URL.createObjectURL(blob);
  385. player.value.poster(currentFrameUrl);
  386. // 清理之前的 blob URL
  387. const oldPoster = player.value.poster();
  388. if (
  389. oldPoster &&
  390. oldPoster.startsWith("blob:") &&
  391. oldPoster !== currentFrameUrl
  392. ) {
  393. URL.revokeObjectURL(oldPoster);
  394. }
  395. }
  396. },
  397. "image/jpeg",
  398. 0.8
  399. );
  400. }
  401. }
  402. });
  403. resolve();
  404. });
  405. player.value.on("loadstart", () => {
  406. loading.value = true;
  407. });
  408. player.value.on("loadeddata", () => {
  409. loading.value = false;
  410. });
  411. player.value.on("canplay", () => {
  412. loading.value = false;
  413. error.value = "";
  414. emit("canplay");
  415. });
  416. player.value.on("play", () => {
  417. emit("play");
  418. });
  419. player.value.on("timeupdate", () => {
  420. emit("timeupdate");
  421. });
  422. player.value.on("seeking", () => {
  423. emit("seeking");
  424. });
  425. player.value.on("error", (event: any) => {
  426. console.error("Video.js 播放错误:", event);
  427. error.value = "视频播放失败";
  428. loading.value = false;
  429. emit("error", error.value);
  430. reject(event);
  431. });
  432. } catch (err) {
  433. console.error("Video.js 初始化失败:", err);
  434. error.value = "播放器初始化失败";
  435. loading.value = false;
  436. emit("error", error.value);
  437. reject(err);
  438. }
  439. });
  440. };
  441. // 销毁播放器
  442. const destroyPlayer = (): void => {
  443. if (player.value) {
  444. player.value.dispose();
  445. player.value = null;
  446. }
  447. };
  448. // 重试功能
  449. const retry = (): void => {
  450. if (retryCount.value >= maxRetries) return;
  451. retryCount.value++;
  452. error.value = "";
  453. emit("retry");
  454. destroyPlayer();
  455. if (props.coverUrl) processCover(props.coverUrl);
  456. if (props.m3u8Url) processVideo(props.m3u8Url);
  457. };
  458. // 停止视频播放
  459. const stopVideo = (): void => {
  460. if (player.value) {
  461. player.value.pause();
  462. player.value.currentTime(0);
  463. }
  464. destroyPlayer();
  465. };
  466. // 事件处理
  467. const handleCoverError = (event: Event): void => {
  468. const img = event.target as HTMLImageElement;
  469. if (img.src.startsWith("blob:")) {
  470. processedCoverUrl.value = props.coverUrl;
  471. }
  472. };
  473. const handleCoverLoad = (): void => {
  474. // 确保图片可见
  475. if (!hasVideoSource.value) {
  476. const img = document.querySelector(
  477. ".video-js-container img"
  478. ) as HTMLImageElement;
  479. if (img) {
  480. img.style.display = "block";
  481. img.style.opacity = "1";
  482. }
  483. }
  484. };
  485. // 监听 props 变化
  486. watch(
  487. () => props.coverUrl,
  488. (newUrl, oldUrl) => {
  489. if (newUrl && newUrl !== oldUrl) {
  490. if (processedCoverUrl.value?.startsWith("blob:")) {
  491. URL.revokeObjectURL(processedCoverUrl.value);
  492. processedCoverUrl.value = "";
  493. }
  494. processCover(newUrl);
  495. } else if (newUrl) {
  496. processCover(newUrl);
  497. }
  498. },
  499. { immediate: true }
  500. );
  501. watch(
  502. () => props.m3u8Url,
  503. (newUrl, oldUrl) => {
  504. if (oldUrl && newUrl !== oldUrl) {
  505. console.log("视频URL变化,停止当前播放并加载新视频", { newUrl, oldUrl });
  506. stopVideo();
  507. if (newUrl) {
  508. processVideo(newUrl);
  509. }
  510. } else if (newUrl) {
  511. processVideo(newUrl);
  512. }
  513. },
  514. { immediate: true }
  515. );
  516. // 组件卸载时清理资源
  517. onUnmounted(() => {
  518. // 清理播放器的 poster blob URL
  519. if (player.value && player.value.poster()) {
  520. const posterUrl = player.value.poster();
  521. if (posterUrl.startsWith("blob:")) {
  522. URL.revokeObjectURL(posterUrl);
  523. }
  524. }
  525. destroyPlayer();
  526. // 清理 Blob URL
  527. if (processedCoverUrl.value?.startsWith("blob:")) {
  528. URL.revokeObjectURL(processedCoverUrl.value);
  529. }
  530. });
  531. // 手动播放方法
  532. const playVideo = (): void => {
  533. console.log("手动播放被调用");
  534. if (player.value) {
  535. player.value.play().catch((err: any) => {
  536. console.error("播放失败:", err);
  537. });
  538. }
  539. };
  540. // 暂停播放方法
  541. const pauseVideo = (): void => {
  542. player.value?.pause();
  543. };
  544. // 暴露方法给父组件
  545. defineExpose({
  546. retry,
  547. stopVideo,
  548. playVideo,
  549. pauseVideo,
  550. processedVideoUrl: computed(() => processedVideoUrl.value),
  551. loading: computed(() => loading.value),
  552. error: computed(() => error.value),
  553. });
  554. </script>
  555. <style scoped>
  556. .video-js-container {
  557. position: relative;
  558. width: 100%;
  559. height: 100%;
  560. background-color: #000;
  561. overflow: hidden;
  562. }
  563. .video-js-container img {
  564. width: 100%;
  565. height: 100%;
  566. object-fit: cover;
  567. opacity: 1;
  568. }
  569. .video-js-wrapper {
  570. width: 100%;
  571. height: 100%;
  572. }
  573. /* 加载中时的视频容器样式 */
  574. .loading-video :deep(.vjs-big-play-button) {
  575. display: none !important;
  576. }
  577. /* Video.js 自定义样式 */
  578. :deep(.video-js) {
  579. width: 100%;
  580. height: 100%;
  581. background-color: #000;
  582. }
  583. /* Video.js poster 样式 */
  584. :deep(.video-js .vjs-poster) {
  585. background-size: cover;
  586. opacity: 1 !important;
  587. background-position: center center;
  588. background-repeat: no-repeat;
  589. z-index: 1;
  590. }
  591. /* 播放时隐藏 poster */
  592. :deep(.video-js.vjs-playing .vjs-poster) {
  593. display: none !important;
  594. }
  595. /* 暂停时显示 poster */
  596. :deep(.video-js.vjs-paused .vjs-poster) {
  597. display: block !important;
  598. opacity: 1 !important;
  599. z-index: 1;
  600. }
  601. /* 自定义中央播放按钮样式 */
  602. :deep(.video-js .vjs-big-play-button) {
  603. font-size: 3em;
  604. line-height: 1.5em;
  605. height: 1.5em;
  606. width: 1.5em;
  607. border: none;
  608. border-radius: 50%;
  609. background-color: rgba(59, 130, 246, 0.8);
  610. box-shadow: 0 0 20px rgba(59, 130, 246, 0.4);
  611. position: absolute;
  612. top: 50%;
  613. left: 50%;
  614. margin-top: -0.75em;
  615. margin-left: -0.75em;
  616. padding: 0;
  617. cursor: pointer;
  618. transition: all 0.3s ease;
  619. z-index: 100;
  620. }
  621. :deep(.video-js:hover .vjs-big-play-button) {
  622. background-color: rgba(59, 130, 246, 1);
  623. transform: scale(1.1);
  624. }
  625. /* 快进 10 秒按钮样式 */
  626. :deep(.video-js .vjs-forward-10-button) {
  627. font-size: 1.3em;
  628. position: relative;
  629. }
  630. :deep(.video-js .vjs-forward-10-button::before) {
  631. content: "\f11d";
  632. font-family: VideoJS;
  633. text-shadow: 0 0 8px rgba(255, 255, 255, 0.3);
  634. }
  635. /* 确保进度条有足够的显示长度 */
  636. :deep(.video-js .vjs-progress-control) {
  637. flex: 1 !important;
  638. min-width: 0 !important;
  639. }
  640. :deep(.video-js .vjs-progress-holder) {
  641. flex: 1 !important;
  642. width: 100% !important;
  643. }
  644. /* iOS 设备特殊样式 */
  645. @supports (-webkit-touch-callout: none) {
  646. :deep(.video-js video) {
  647. -webkit-playsinline: true;
  648. playsinline: true;
  649. }
  650. :deep(.video-js) {
  651. -webkit-transform: translateZ(0);
  652. transform: translateZ(0);
  653. }
  654. }
  655. .loading-overlay {
  656. position: absolute;
  657. top: 0;
  658. left: 0;
  659. right: 0;
  660. bottom: 0;
  661. display: flex;
  662. flex-direction: column;
  663. align-items: center;
  664. justify-content: center;
  665. background: rgba(0, 0, 0, 0.6);
  666. backdrop-filter: blur(4px);
  667. color: white;
  668. z-index: 10;
  669. border-radius: 8px;
  670. }
  671. .spinner {
  672. width: 40px;
  673. height: 40px;
  674. border: 3px solid rgba(59, 130, 246, 0.2);
  675. border-top: 3px solid rgba(59, 130, 246, 1);
  676. border-radius: 50%;
  677. animation: spin 1s cubic-bezier(0.4, 0, 0.2, 1) infinite;
  678. margin-bottom: 12px;
  679. box-shadow: 0 0 15px rgba(59, 130, 246, 0.3);
  680. }
  681. @keyframes spin {
  682. 0% {
  683. transform: rotate(0deg);
  684. }
  685. 100% {
  686. transform: rotate(360deg);
  687. }
  688. }
  689. .loading-text {
  690. font-size: 16px;
  691. color: white;
  692. font-weight: 500;
  693. text-shadow: 0 0 10px rgba(255, 255, 255, 0.5);
  694. animation: pulse 1.5s ease-in-out infinite;
  695. }
  696. @keyframes pulse {
  697. 0%,
  698. 100% {
  699. opacity: 0.8;
  700. }
  701. 50% {
  702. opacity: 1;
  703. }
  704. }
  705. .error-overlay {
  706. position: absolute;
  707. top: 0;
  708. left: 0;
  709. right: 0;
  710. bottom: 0;
  711. display: flex;
  712. align-items: center;
  713. justify-content: center;
  714. background: rgba(0, 0, 0, 0.75);
  715. backdrop-filter: blur(4px);
  716. z-index: 10;
  717. border-radius: 8px;
  718. }
  719. .error-content {
  720. text-align: center;
  721. color: white;
  722. padding: 24px;
  723. background: rgba(255, 255, 255, 0.1);
  724. border-radius: 12px;
  725. backdrop-filter: blur(8px);
  726. box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
  727. max-width: 300px;
  728. width: 80%;
  729. }
  730. .error-icon {
  731. font-size: 40px;
  732. margin-bottom: 16px;
  733. animation: shake 0.5s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
  734. }
  735. @keyframes shake {
  736. 10%,
  737. 90% {
  738. transform: translate3d(-1px, 0, 0);
  739. }
  740. 20%,
  741. 80% {
  742. transform: translate3d(2px, 0, 0);
  743. }
  744. 30%,
  745. 50%,
  746. 70% {
  747. transform: translate3d(-3px, 0, 0);
  748. }
  749. 40%,
  750. 60% {
  751. transform: translate3d(3px, 0, 0);
  752. }
  753. }
  754. .error-text {
  755. font-size: 16px;
  756. margin-bottom: 20px;
  757. color: #ff6b6b;
  758. line-height: 1.5;
  759. }
  760. .retry-btn {
  761. padding: 10px 20px;
  762. background: linear-gradient(to right, #3b82f6, #60a5fa);
  763. color: white;
  764. border: none;
  765. border-radius: 8px;
  766. font-size: 15px;
  767. font-weight: 600;
  768. cursor: pointer;
  769. transition: all 0.3s ease;
  770. box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
  771. }
  772. .retry-btn:hover {
  773. background: linear-gradient(to right, #2563eb, #3b82f6);
  774. transform: translateY(-2px);
  775. box-shadow: 0 6px 16px rgba(59, 130, 246, 0.5);
  776. }
  777. /* 移动端优化 */
  778. @media screen and (max-width: 768px) {
  779. :deep(.video-js .vjs-big-play-button) {
  780. font-size: 2.5em;
  781. height: 1.5em;
  782. width: 1.5em;
  783. margin-top: -0.75em;
  784. margin-left: -0.75em;
  785. }
  786. .error-content {
  787. padding: 18px;
  788. max-width: 260px;
  789. }
  790. .error-icon {
  791. font-size: 32px;
  792. }
  793. .error-text {
  794. font-size: 14px;
  795. }
  796. .retry-btn {
  797. padding: 8px 16px;
  798. font-size: 14px;
  799. }
  800. }
  801. </style>