| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900 |
- <template>
- <div class="video-js-container">
- <!-- 封面图片 - 只在没有视频源时显示 -->
- <img
- v-if="!hasVideoSource && processedCoverUrl"
- :src="processedCoverUrl"
- :alt="alt"
- :class="coverClass"
- @error="handleCoverError"
- @load="handleCoverLoad"
- style="
- width: 100% !important;
- height: 100% !important;
- opacity: 1 !important;
- "
- />
- <!-- Video.js 播放器容器 - 只在有视频源时显示 -->
- <div
- v-if="hasVideoSource"
- ref="videoContainer"
- class="video-js-wrapper"
- :class="[videoClass, { 'loading-video': loading }]"
- ></div>
- <!-- 加载状态 -->
- <div v-if="loading" class="loading-overlay">
- <div class="spinner"></div>
- <span class="loading-text">加载中...</span>
- </div>
- <!-- 错误状态 -->
- <div v-if="error && !hideError" class="error-overlay">
- <div class="error-content">
- <div class="error-icon">⚠️</div>
- <div class="error-text">{{ error }}</div>
- <button v-if="showRetryButton" @click="retry" class="retry-btn">
- 重试
- </button>
- </div>
- </div>
- </div>
- </template>
- <script setup lang="ts">
- import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
- import videojs from "video.js";
- import "video.js/dist/video-js.css";
- // Props
- interface Props {
- coverUrl?: string;
- m3u8Url?: string;
- alt?: string;
- coverClass?: string;
- videoClass?: string;
- autoPlay?: boolean;
- hideError?: boolean;
- enableRetry?: boolean;
- }
- const props = withDefaults(defineProps<Props>(), {
- coverUrl: "",
- m3u8Url: "",
- alt: "Video Cover",
- coverClass: "w-full h-full object-cover",
- videoClass: "w-full h-full object-contain",
- autoPlay: false,
- hideError: false,
- enableRetry: true,
- });
- // Emits
- const emit = defineEmits<{
- coverLoaded: [url: string];
- videoLoaded: [url: string];
- error: [error: string];
- retry: [];
- play: [];
- timeupdate: [];
- seeking: [];
- canplay: [];
- }>();
- // 响应式数据
- const processedCoverUrl = ref<string>("");
- const processedVideoUrl = ref<string>("");
- const loading = ref(false);
- const error = ref("");
- const retryCount = ref(0);
- const maxRetries = 3;
- const videoContainer = ref<HTMLDivElement>();
- const player = ref<any>(null);
- // 计算属性
- const hasVideoSource = computed(
- () => !!props.m3u8Url && props.m3u8Url.trim() !== ""
- );
- const showRetryButton = computed(
- () => props.enableRetry && retryCount.value < maxRetries
- );
- // 核心解密函数(仅用于封面)
- const loader = async (url: string): Promise<Blob | string> => {
- const response = await fetch(url, { mode: "cors" });
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
- const reader = response.body!.getReader();
- const key = new Uint8Array(8);
- const buffer: Uint8Array[] = [];
- let len = 0;
- let offset = 0;
- try {
- for (;;) {
- const read = await reader.read();
- if (read.done) break;
- if (!read.value) continue;
- if (len < 8) {
- let i = 0;
- while (i < read.value.length) {
- key[len++] = read.value[i++];
- if (len > 7) break;
- }
- if (len < 8) continue;
- read.value = read.value.slice(i);
- }
- // 复制数据以避免修改原始数据
- const decryptedValue = new Uint8Array(read.value.length);
- for (let i = 0; i < read.value.length; ++i) {
- decryptedValue[i] = read.value[i] ^ key[offset++ % 8];
- }
- buffer.push(decryptedValue);
- }
- // 合并所有缓冲区
- const totalLength = buffer.reduce((sum, arr) => sum + arr.length, 0);
- const result = new Uint8Array(totalLength);
- let position = 0;
- for (const arr of buffer) {
- result.set(arr, position);
- position += arr.length;
- }
- const isPoster = url.includes("cover");
- const type = isPoster ? "application/octet-stream" : "text/plain";
- const blob = new Blob([result], { type: type });
- return isPoster ? blob : await blob.text();
- } finally {
- reader.releaseLock();
- }
- };
- // 处理封面 URL
- const processCover = async (url: string): Promise<void> => {
- if (!url) return;
- try {
- loading.value = true;
- error.value = "";
- // 检查是否需要解密(仅对封面进行解密)
- if (url.includes("cover")) {
- try {
- const decryptedData = await loader(url);
- if (decryptedData instanceof Blob) {
- // 直接使用解密后的 Blob 创建 URL
- const blobUrl = URL.createObjectURL(decryptedData);
- processedCoverUrl.value = blobUrl;
- } else {
- processedCoverUrl.value = decryptedData;
- }
- } catch (decryptErr) {
- console.error("封面解密失败,使用原始URL:", decryptErr);
- processedCoverUrl.value = url;
- }
- } else {
- console.log("使用原始封面URL");
- processedCoverUrl.value = url;
- }
- // 预加载图片以确保它可以正确显示
- const img = new Image();
- img.onload = () => {
- emit("coverLoaded", processedCoverUrl.value);
- };
- img.onerror = () => {
- console.error("封面图片加载失败");
- // 如果加载失败,尝试使用原始URL
- if (processedCoverUrl.value !== url) {
- processedCoverUrl.value = url;
- emit("coverLoaded", url);
- }
- };
- img.src = processedCoverUrl.value;
- } catch (err) {
- console.error("处理封面失败:", err);
- // 封面处理失败时使用原始 URL
- processedCoverUrl.value = url;
- emit("coverLoaded", processedCoverUrl.value);
- } finally {
- loading.value = false;
- }
- };
- // 处理视频 URL - 直接使用
- const processVideo = async (url: string): Promise<void> => {
- if (!url) return;
- processedVideoUrl.value = url;
- await nextTick();
- await initVideoJSPlayer();
- emit("videoLoaded", url);
- };
- // 初始化 Video.js 播放器
- const initVideoJSPlayer = async (): Promise<void> => {
- return new Promise(async (resolve, reject) => {
- if (!videoContainer.value || !processedVideoUrl.value) {
- console.log("初始化失败:缺少容器或视频URL");
- reject(new Error("缺少容器或视频URL"));
- return;
- }
- // 确保容器是干净的
- if (videoContainer.value) {
- videoContainer.value.innerHTML = "";
- }
- // 处理封面 URL
- let posterUrl = "";
- if (props.coverUrl) {
- await processCover(props.coverUrl);
- posterUrl = processedCoverUrl.value;
- }
- // 创建 video 元素
- const videoElement = document.createElement("video");
- videoElement.className = "video-js vjs-big-play-centered";
- videoElement.controls = true;
- videoElement.preload = "auto";
- // iOS 特殊配置,防止自动全屏
- videoElement.setAttribute("playsinline", "true");
- videoElement.setAttribute("webkit-playsinline", "true");
- videoElement.setAttribute("x-webkit-airplay", "allow");
- // 设置封面
- if (posterUrl) {
- videoElement.poster = posterUrl;
- }
- // 添加到容器
- videoContainer.value.appendChild(videoElement);
- const options = {
- controls: true,
- responsive: true,
- fluid: true,
- preload: "auto",
- techOrder: ["html5"],
- bigPlayButton: true, // 启用大型播放按钮
- // iOS 特殊配置
- playsinline: true,
- webkitPlaysinline: true,
- userActions: {
- // 保持默认的点击行为,让控制栏可以正常显示/隐藏
- click: true,
- doubleClick: true,
- hotkeys: true,
- },
- controlBar: {
- // 自定义控制栏
- children: [
- "playToggle", // 播放/暂停按钮
- "forward10Button", // 快进 10 秒按钮
- "forwardButton", // 快进按钮
- "currentTimeDisplay", // 当前时间
- "timeDivider", // 时间分隔符
- "durationDisplay", // 总时长
- "progressControl", // 进度条
- "volumePanel", // 音量控制
- "fullscreenToggle", // 全屏按钮
- ],
- // 确保全屏按钮在右侧
- fullscreenToggle: {
- index: 9,
- },
- },
- // 使用默认的 HTML5 配置,Video.js 内置 HLS 支持
- sources: processedVideoUrl.value
- ? [
- {
- src: processedVideoUrl.value,
- type: "application/x-mpegURL",
- },
- ]
- : [],
- poster: posterUrl, // 使用处理后的封面URL
- };
- try {
- // 注册快进按钮组件
- const registerSeekButtons = () => {
- try {
- // 注册快进按钮
- const ForwardButton = videojs.getComponent("Button");
- class ForwardButtonComponent extends ForwardButton {
- constructor(player: any, options: any) {
- super(player, options);
- (this as any).controlText("快进10秒");
- }
- handleClick() {
- const time = (this as any).player().currentTime();
- const duration = (this as any).player().duration();
- if (typeof time === "number" && typeof duration === "number") {
- (this as any)
- .player()
- .currentTime(Math.min(duration, time + 10));
- }
- }
- buildCSSClass() {
- return `vjs-forward-button ${super.buildCSSClass()}`;
- }
- }
- videojs.registerComponent("ForwardButton", ForwardButtonComponent);
- // 注册快进 10 秒按钮
- const Forward10Button = videojs.getComponent("Button");
- class Forward10ButtonComponent extends Forward10Button {
- constructor(player: any, options: any) {
- super(player, options);
- (this as any).controlText("快进10秒");
- }
- handleClick() {
- const time = (this as any).player().currentTime();
- const duration = (this as any).player().duration();
- if (typeof time === "number" && typeof duration === "number") {
- (this as any)
- .player()
- .currentTime(Math.min(duration, time + 10));
- }
- }
- buildCSSClass() {
- return `vjs-forward-10-button ${super.buildCSSClass()}`;
- }
- }
- videojs.registerComponent(
- "Forward10Button",
- Forward10ButtonComponent
- );
- } catch (err) {
- console.error("注册快进快退按钮失败:", err);
- }
- };
- registerSeekButtons();
- player.value = videojs(videoElement, options);
- // 自定义点击行为
- player.value.ready(() => {
- error.value = "";
- loading.value = false;
- emit("canplay");
- // 使用 Video.js 默认的点击行为,不需要自定义处理
- // 确保 iOS 设备上的内联播放设置
- const videoEl = player.value.el().querySelector("video");
- if (videoEl) {
- videoEl.setAttribute("playsinline", "true");
- videoEl.setAttribute("webkit-playsinline", "true");
- videoEl.setAttribute("x-webkit-airplay", "allow");
- }
- // 监听播放和暂停事件,动态更新 poster
- player.value.on("play", () => {
- // 播放时隐藏 poster
- if (player.value.poster()) {
- player.value.poster("");
- }
- });
- player.value.on("pause", () => {
- // 暂停时显示当前帧作为 poster
- const videoElement = player.value.el().querySelector("video");
- if (videoElement) {
- // 创建 canvas 来捕获当前帧
- const canvas = document.createElement("canvas");
- const ctx = canvas.getContext("2d");
- if (ctx) {
- canvas.width = videoElement.videoWidth;
- canvas.height = videoElement.videoHeight;
- ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
- // 将 canvas 转换为 blob URL
- canvas.toBlob(
- (blob) => {
- if (blob) {
- const currentFrameUrl = URL.createObjectURL(blob);
- player.value.poster(currentFrameUrl);
- // 清理之前的 blob URL
- const oldPoster = player.value.poster();
- if (
- oldPoster &&
- oldPoster.startsWith("blob:") &&
- oldPoster !== currentFrameUrl
- ) {
- URL.revokeObjectURL(oldPoster);
- }
- }
- },
- "image/jpeg",
- 0.8
- );
- }
- }
- });
- resolve();
- });
- player.value.on("loadstart", () => {
- loading.value = true;
- });
- player.value.on("loadeddata", () => {
- loading.value = false;
- });
- player.value.on("canplay", () => {
- loading.value = false;
- error.value = "";
- emit("canplay");
- });
- player.value.on("play", () => {
- emit("play");
- });
- player.value.on("timeupdate", () => {
- emit("timeupdate");
- });
- player.value.on("seeking", () => {
- emit("seeking");
- });
- player.value.on("error", (event: any) => {
- console.error("Video.js 播放错误:", event);
- error.value = "视频播放失败";
- loading.value = false;
- emit("error", error.value);
- reject(event);
- });
- } catch (err) {
- console.error("Video.js 初始化失败:", err);
- error.value = "播放器初始化失败";
- loading.value = false;
- emit("error", error.value);
- reject(err);
- }
- });
- };
- // 销毁播放器
- const destroyPlayer = (): void => {
- if (player.value) {
- player.value.dispose();
- player.value = null;
- }
- };
- // 重试功能
- const retry = (): void => {
- if (retryCount.value >= maxRetries) return;
- retryCount.value++;
- error.value = "";
- emit("retry");
- destroyPlayer();
- if (props.coverUrl) processCover(props.coverUrl);
- if (props.m3u8Url) processVideo(props.m3u8Url);
- };
- // 停止视频播放
- const stopVideo = (): void => {
- if (player.value) {
- player.value.pause();
- player.value.currentTime(0);
- }
- destroyPlayer();
- };
- // 事件处理
- const handleCoverError = (event: Event): void => {
- const img = event.target as HTMLImageElement;
- if (img.src.startsWith("blob:")) {
- processedCoverUrl.value = props.coverUrl;
- }
- };
- const handleCoverLoad = (): void => {
- // 确保图片可见
- if (!hasVideoSource.value) {
- const img = document.querySelector(
- ".video-js-container img"
- ) as HTMLImageElement;
- if (img) {
- img.style.display = "block";
- img.style.opacity = "1";
- }
- }
- };
- // 监听 props 变化
- watch(
- () => props.coverUrl,
- (newUrl, oldUrl) => {
- if (newUrl && newUrl !== oldUrl) {
- if (processedCoverUrl.value?.startsWith("blob:")) {
- URL.revokeObjectURL(processedCoverUrl.value);
- processedCoverUrl.value = "";
- }
- processCover(newUrl);
- } else if (newUrl) {
- processCover(newUrl);
- }
- },
- { immediate: true }
- );
- watch(
- () => props.m3u8Url,
- (newUrl, oldUrl) => {
- if (oldUrl && newUrl !== oldUrl) {
- console.log("视频URL变化,停止当前播放并加载新视频", { newUrl, oldUrl });
- stopVideo();
- if (newUrl) {
- processVideo(newUrl);
- }
- } else if (newUrl) {
- processVideo(newUrl);
- }
- },
- { immediate: true }
- );
- // 组件卸载时清理资源
- onUnmounted(() => {
- // 清理播放器的 poster blob URL
- if (player.value && player.value.poster()) {
- const posterUrl = player.value.poster();
- if (posterUrl.startsWith("blob:")) {
- URL.revokeObjectURL(posterUrl);
- }
- }
- destroyPlayer();
- // 清理 Blob URL
- if (processedCoverUrl.value?.startsWith("blob:")) {
- URL.revokeObjectURL(processedCoverUrl.value);
- }
- });
- // 手动播放方法
- const playVideo = (): void => {
- console.log("手动播放被调用");
- if (player.value) {
- player.value.play().catch((err: any) => {
- console.error("播放失败:", err);
- });
- }
- };
- // 暂停播放方法
- const pauseVideo = (): void => {
- player.value?.pause();
- };
- // 暴露方法给父组件
- defineExpose({
- retry,
- stopVideo,
- playVideo,
- pauseVideo,
- processedVideoUrl: computed(() => processedVideoUrl.value),
- loading: computed(() => loading.value),
- error: computed(() => error.value),
- });
- </script>
- <style scoped>
- .video-js-container {
- position: relative;
- width: 100%;
- height: 100%;
- background-color: #000;
- overflow: hidden;
- }
- .video-js-container img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- opacity: 1;
- }
- .video-js-wrapper {
- width: 100%;
- height: 100%;
- }
- /* 加载中时的视频容器样式 */
- .loading-video :deep(.vjs-big-play-button) {
- display: none !important;
- }
- /* Video.js 自定义样式 */
- :deep(.video-js) {
- width: 100%;
- height: 100%;
- background-color: #000;
- }
- /* Video.js poster 样式 */
- :deep(.video-js .vjs-poster) {
- background-size: cover;
- opacity: 1 !important;
- background-position: center center;
- background-repeat: no-repeat;
- z-index: 1;
- }
- /* 播放时隐藏 poster */
- :deep(.video-js.vjs-playing .vjs-poster) {
- display: none !important;
- }
- /* 暂停时显示 poster */
- :deep(.video-js.vjs-paused .vjs-poster) {
- display: block !important;
- opacity: 1 !important;
- z-index: 1;
- }
- /* 自定义中央播放按钮样式 */
- :deep(.video-js .vjs-big-play-button) {
- font-size: 3em;
- line-height: 1.5em;
- height: 1.5em;
- width: 1.5em;
- border: none;
- border-radius: 50%;
- background-color: rgba(59, 130, 246, 0.8);
- box-shadow: 0 0 20px rgba(59, 130, 246, 0.4);
- position: absolute;
- top: 50%;
- left: 50%;
- margin-top: -0.75em;
- margin-left: -0.75em;
- padding: 0;
- cursor: pointer;
- transition: all 0.3s ease;
- z-index: 100;
- }
- :deep(.video-js:hover .vjs-big-play-button) {
- background-color: rgba(59, 130, 246, 1);
- transform: scale(1.1);
- }
- /* 快进 10 秒按钮样式 */
- :deep(.video-js .vjs-forward-10-button) {
- font-size: 1.3em;
- position: relative;
- transition: all 0.2s ease;
- }
- :deep(.video-js .vjs-forward-10-button::before) {
- content: "\e9b1"; /* pi-angle-double-right */
- font-family: "primeicons", "VideoJS", sans-serif;
- text-shadow: 0 0 8px rgba(255, 255, 255, 0.3);
- display: inline-block;
- line-height: 1;
- }
- /* 按钮悬停效果 */
- :deep(.video-js .vjs-forward-10-button:hover) {
- transform: scale(1.1);
- color: rgba(255, 255, 255, 0.9);
- }
- /* 按钮激活效果 */
- :deep(.video-js .vjs-forward-10-button:active) {
- transform: scale(0.95);
- }
- /* 确保进度条有足够的显示长度 */
- :deep(.video-js .vjs-progress-control) {
- flex: 1 !important;
- min-width: 0 !important;
- }
- :deep(.video-js .vjs-progress-holder) {
- flex: 1 !important;
- width: 100% !important;
- }
- /* iOS 设备特殊样式 */
- @supports (-webkit-touch-callout: none) {
- :deep(.video-js video) {
- -webkit-playsinline: true;
- playsinline: true;
- }
- :deep(.video-js) {
- -webkit-transform: translateZ(0);
- transform: translateZ(0);
- }
- }
- .loading-overlay {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- background: rgba(0, 0, 0, 0.6);
- backdrop-filter: blur(4px);
- color: white;
- z-index: 10;
- border-radius: 8px;
- }
- .spinner {
- width: 40px;
- height: 40px;
- border: 3px solid rgba(59, 130, 246, 0.2);
- border-top: 3px solid rgba(59, 130, 246, 1);
- border-radius: 50%;
- animation: spin 1s cubic-bezier(0.4, 0, 0.2, 1) infinite;
- margin-bottom: 12px;
- box-shadow: 0 0 15px rgba(59, 130, 246, 0.3);
- }
- @keyframes spin {
- 0% {
- transform: rotate(0deg);
- }
- 100% {
- transform: rotate(360deg);
- }
- }
- .loading-text {
- font-size: 16px;
- color: white;
- font-weight: 500;
- text-shadow: 0 0 10px rgba(255, 255, 255, 0.5);
- animation: pulse 1.5s ease-in-out infinite;
- }
- @keyframes pulse {
- 0%,
- 100% {
- opacity: 0.8;
- }
- 50% {
- opacity: 1;
- }
- }
- .error-overlay {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(0, 0, 0, 0.75);
- backdrop-filter: blur(4px);
- z-index: 10;
- border-radius: 8px;
- }
- .error-content {
- text-align: center;
- color: white;
- padding: 24px;
- background: rgba(255, 255, 255, 0.1);
- border-radius: 12px;
- backdrop-filter: blur(8px);
- box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
- max-width: 300px;
- width: 80%;
- }
- .error-icon {
- font-size: 40px;
- margin-bottom: 16px;
- animation: shake 0.5s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
- }
- @keyframes shake {
- 10%,
- 90% {
- transform: translate3d(-1px, 0, 0);
- }
- 20%,
- 80% {
- transform: translate3d(2px, 0, 0);
- }
- 30%,
- 50%,
- 70% {
- transform: translate3d(-3px, 0, 0);
- }
- 40%,
- 60% {
- transform: translate3d(3px, 0, 0);
- }
- }
- .error-text {
- font-size: 16px;
- margin-bottom: 20px;
- color: #ff6b6b;
- line-height: 1.5;
- }
- .retry-btn {
- padding: 10px 20px;
- background: linear-gradient(to right, #3b82f6, #60a5fa);
- color: white;
- border: none;
- border-radius: 8px;
- font-size: 15px;
- font-weight: 600;
- cursor: pointer;
- transition: all 0.3s ease;
- box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
- }
- .retry-btn:hover {
- background: linear-gradient(to right, #2563eb, #3b82f6);
- transform: translateY(-2px);
- box-shadow: 0 6px 16px rgba(59, 130, 246, 0.5);
- }
- /* 移动端优化 */
- @media screen and (max-width: 768px) {
- :deep(.video-js .vjs-big-play-button) {
- font-size: 2.5em;
- height: 1.5em;
- width: 1.5em;
- margin-top: -0.75em;
- margin-left: -0.75em;
- }
- .error-content {
- padding: 18px;
- max-width: 260px;
- }
- .error-icon {
- font-size: 32px;
- }
- .error-text {
- font-size: 14px;
- }
- .retry-btn {
- padding: 8px 16px;
- font-size: 14px;
- }
- }
- </style>
|