|
|
@@ -1,1512 +0,0 @@
|
|
|
-<template>
|
|
|
- <div class="video-processor" @click="toggleControls">
|
|
|
- <!-- 封面图片 -->
|
|
|
- <img
|
|
|
- v-if="processedCoverUrl && !isVideoMode"
|
|
|
- :src="processedCoverUrl"
|
|
|
- :alt="alt"
|
|
|
- :class="coverClass"
|
|
|
- @error="handleCoverError"
|
|
|
- @load="handleCoverLoad"
|
|
|
- />
|
|
|
-
|
|
|
- <!-- 视频播放器 -->
|
|
|
- <video
|
|
|
- v-if="isVideoMode"
|
|
|
- ref="videoElement"
|
|
|
- :class="videoClass"
|
|
|
- :poster="processedCoverUrl"
|
|
|
- preload="metadata"
|
|
|
- playsinline
|
|
|
- webkit-playsinline
|
|
|
- x5-playsinline
|
|
|
- x5-video-player-type="h5"
|
|
|
- x5-video-player-fullscreen="true"
|
|
|
- x5-video-orientation="landscape"
|
|
|
- @loadstart="onVideoLoadStart"
|
|
|
- @loadeddata="onVideoLoadedData"
|
|
|
- @error="onVideoError"
|
|
|
- @canplay="onVideoCanPlay"
|
|
|
- @play="onVideoPlayEvent"
|
|
|
- @pause="onVideoPauseEvent"
|
|
|
- @timeupdate="onVideoTimeUpdate"
|
|
|
- @seeking="onVideoSeeking"
|
|
|
- @durationchange="onDurationChange"
|
|
|
- @ended="onVideoEnded"
|
|
|
- >
|
|
|
- 您的浏览器不支持视频播放
|
|
|
- </video>
|
|
|
-
|
|
|
- <!-- 自定义播放控制条 -->
|
|
|
- <div
|
|
|
- v-if="isVideoMode && !error"
|
|
|
- class="custom-controls"
|
|
|
- :class="{ show: showControls }"
|
|
|
- @click.stop
|
|
|
- >
|
|
|
- <!-- 播放/暂停按钮(中央) -->
|
|
|
- <div v-if="!isPlaying" class="play-button-center" @click="togglePlay">
|
|
|
- <svg class="w-16 h-16" fill="white" viewBox="0 0 24 24">
|
|
|
- <path d="M8 5v14l11-7z" />
|
|
|
- </svg>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 底部控制栏 -->
|
|
|
- <div class="controls-bottom">
|
|
|
- <!-- 进度条 -->
|
|
|
- <div class="progress-container" @click="seekVideo">
|
|
|
- <div class="progress-bar">
|
|
|
- <div
|
|
|
- class="progress-played"
|
|
|
- :style="{ width: progress + '%' }"
|
|
|
- ></div>
|
|
|
- <div
|
|
|
- class="progress-buffered"
|
|
|
- :style="{ width: buffered + '%' }"
|
|
|
- ></div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 控制按钮区域 -->
|
|
|
- <div class="controls-buttons">
|
|
|
- <!-- 左侧:播放/暂停 + 时间 -->
|
|
|
- <div class="controls-left">
|
|
|
- <button class="control-btn" @click="togglePlay">
|
|
|
- <svg
|
|
|
- v-if="!isPlaying"
|
|
|
- class="w-6 h-6"
|
|
|
- fill="white"
|
|
|
- viewBox="0 0 24 24"
|
|
|
- >
|
|
|
- <path d="M8 5v14l11-7z" />
|
|
|
- </svg>
|
|
|
- <svg v-else class="w-6 h-6" fill="white" viewBox="0 0 24 24">
|
|
|
- <path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
|
|
- </svg>
|
|
|
- </button>
|
|
|
- <span class="time-display"
|
|
|
- >{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span
|
|
|
- >
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 右侧:音量 + 全屏 -->
|
|
|
- <div class="controls-right">
|
|
|
- <!-- 音量控制 -->
|
|
|
- <div class="volume-control">
|
|
|
- <button class="control-btn" @click="toggleMute">
|
|
|
- <svg
|
|
|
- v-if="!isMuted && volume > 0.5"
|
|
|
- class="w-6 h-6"
|
|
|
- fill="white"
|
|
|
- viewBox="0 0 24 24"
|
|
|
- >
|
|
|
- <path
|
|
|
- d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
|
|
|
- />
|
|
|
- </svg>
|
|
|
- <svg
|
|
|
- v-else-if="!isMuted && volume > 0"
|
|
|
- class="w-6 h-6"
|
|
|
- fill="white"
|
|
|
- viewBox="0 0 24 24"
|
|
|
- >
|
|
|
- <path d="M7 9v6h4l5 5V4l-5 5H7z" />
|
|
|
- </svg>
|
|
|
- <svg v-else class="w-6 h-6" fill="white" viewBox="0 0 24 24">
|
|
|
- <path
|
|
|
- d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"
|
|
|
- />
|
|
|
- </svg>
|
|
|
- </button>
|
|
|
- <input
|
|
|
- v-if="showVolumeSlider"
|
|
|
- type="range"
|
|
|
- class="volume-slider"
|
|
|
- min="0"
|
|
|
- max="100"
|
|
|
- :value="volume * 100"
|
|
|
- @input="changeVolume"
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 全屏按钮 -->
|
|
|
- <button
|
|
|
- class="control-btn fullscreen-btn"
|
|
|
- @click="toggleFullscreen"
|
|
|
- >
|
|
|
- <svg
|
|
|
- v-if="!isFullscreen"
|
|
|
- class="w-6 h-6"
|
|
|
- fill="white"
|
|
|
- viewBox="0 0 24 24"
|
|
|
- >
|
|
|
- <path
|
|
|
- d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"
|
|
|
- />
|
|
|
- </svg>
|
|
|
- <svg v-else class="w-6 h-6" fill="white" viewBox="0 0 24 24">
|
|
|
- <path
|
|
|
- d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"
|
|
|
- />
|
|
|
- </svg>
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </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 Hls from "hls.js";
|
|
|
-
|
|
|
-// 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 videoElement = ref<HTMLVideoElement>();
|
|
|
-const hlsInstance = ref<Hls | null>(null);
|
|
|
-
|
|
|
-// 播放器控制状态
|
|
|
-const isPlaying = ref(false);
|
|
|
-const currentTime = ref(0);
|
|
|
-const duration = ref(0);
|
|
|
-const progress = ref(0);
|
|
|
-const buffered = ref(0);
|
|
|
-const volume = ref(1);
|
|
|
-const isMuted = ref(false);
|
|
|
-const isFullscreen = ref(false);
|
|
|
-const showControls = ref(true);
|
|
|
-const showVolumeSlider = ref(true); // 桌面端显示音量滑块,移动端通过CSS隐藏
|
|
|
-let controlsTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
-let errorDebounceTimer: ReturnType<typeof setTimeout> | null = null; // 错误防抖定时器
|
|
|
-const isVideoLoading = ref(false); // 视频是否正在加载
|
|
|
-
|
|
|
-// 计算属性
|
|
|
-const isVideoMode = computed(() => !!props.m3u8Url);
|
|
|
-const showRetryButton = computed(
|
|
|
- () => props.enableRetry && retryCount.value < maxRetries
|
|
|
-);
|
|
|
-
|
|
|
-// 核心解密函数(基于原始 JS)
|
|
|
-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") || url.includes("play")) {
|
|
|
- const decryptedData = await loader(url);
|
|
|
-
|
|
|
- if (decryptedData instanceof Blob) {
|
|
|
- // 直接使用解密后的 Blob 创建 URL
|
|
|
- processedCoverUrl.value = URL.createObjectURL(decryptedData);
|
|
|
- } else {
|
|
|
- processedCoverUrl.value = decryptedData;
|
|
|
- }
|
|
|
- } else {
|
|
|
- processedCoverUrl.value = url;
|
|
|
- }
|
|
|
-
|
|
|
- emit("coverLoaded", processedCoverUrl.value);
|
|
|
- } catch (err) {
|
|
|
- // 封面处理失败时使用原始 URL
|
|
|
- processedCoverUrl.value = url;
|
|
|
- emit("coverLoaded", processedCoverUrl.value);
|
|
|
- } finally {
|
|
|
- loading.value = false;
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-// 判断是否为标准的 HLS 流地址
|
|
|
-const isStandardHlsUrl = (url: string): boolean => {
|
|
|
- // 简单判断:只要包含 .m3u8 就是 HLS 流
|
|
|
- return url.includes(".m3u8");
|
|
|
-};
|
|
|
-
|
|
|
-// 处理视频 URL
|
|
|
-const processVideo = async (url: string): Promise<void> => {
|
|
|
- if (!url) return;
|
|
|
-
|
|
|
- try {
|
|
|
- loading.value = true;
|
|
|
- error.value = "";
|
|
|
-
|
|
|
- // 检查是否为标准的 HLS 流地址(不需要解密的)
|
|
|
- if (
|
|
|
- isStandardHlsUrl(url) &&
|
|
|
- !url.includes("play") &&
|
|
|
- !url.includes("cover")
|
|
|
- ) {
|
|
|
- processedVideoUrl.value = url;
|
|
|
- await nextTick();
|
|
|
- await initVideoPlayer();
|
|
|
- emit("videoLoaded", processedVideoUrl.value);
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- let processedUrl = url;
|
|
|
- if (processedUrl.includes("cover")) {
|
|
|
- processedUrl = processedUrl.replace("cover", "play");
|
|
|
- }
|
|
|
-
|
|
|
- // 检查是否需要解密
|
|
|
- if (processedUrl.includes("play") || processedUrl.includes("cover")) {
|
|
|
- const decryptedData = await loader(processedUrl);
|
|
|
-
|
|
|
- if (typeof decryptedData === "string") {
|
|
|
- const playlist = processM3u8Content(decryptedData, processedUrl);
|
|
|
-
|
|
|
- // 根据不同浏览器选择合适的 MIME 类型和 URL 格式
|
|
|
- let mimeType = "application/x-mpegURL";
|
|
|
-
|
|
|
- if (isQuarkBrowser() && !isIOSQuarkBrowser()) {
|
|
|
- // Android 夸克使用 Apple 格式
|
|
|
- mimeType = "application/vnd.apple.mpegurl";
|
|
|
- console.log("夸克浏览器使用 Apple HLS 格式");
|
|
|
- }
|
|
|
-
|
|
|
- if (isQQBrowser()) {
|
|
|
- // QQ 浏览器(X5内核)必须使用 Apple 格式
|
|
|
- mimeType = "application/x-mpegURL";
|
|
|
- console.log("QQ浏览器使用 Apple HLS 格式(X5内核要求)");
|
|
|
- }
|
|
|
-
|
|
|
- // iOS 夸克浏览器:使用 Data URL(iOS 不支持 Blob URL + HLS.js)
|
|
|
- if (isIOSQuarkBrowser()) {
|
|
|
- try {
|
|
|
- const base64 = btoa(unescape(encodeURIComponent(playlist)));
|
|
|
- processedVideoUrl.value = `data:${mimeType};base64,${base64}`;
|
|
|
- console.log("iOS 夸克浏览器使用 Data URL");
|
|
|
- } catch (e) {
|
|
|
- console.error("Data URL 生成失败:", e);
|
|
|
- error.value =
|
|
|
- "抱歉,iOS 夸克浏览器暂不支持播放此视频,建议使用 Safari 打开";
|
|
|
- emit("error", error.value);
|
|
|
- loading.value = false;
|
|
|
- return;
|
|
|
- }
|
|
|
- }
|
|
|
- // else if (isQQBrowser()) {
|
|
|
- // // QQ浏览器:使用正确的MIME类型
|
|
|
- // console.log("检测到QQ浏览器,使用HLS.js兼容模式");
|
|
|
-
|
|
|
- // try {
|
|
|
- // // 使用正确的HLS MIME类型
|
|
|
- // const blob = new Blob([playlist], { type: "application/vnd.apple.mpegurl" });
|
|
|
- // processedVideoUrl.value = URL.createObjectURL(blob);
|
|
|
- // console.log("QQ浏览器使用Blob URL,MIME类型:", "application/vnd.apple.mpegurl");
|
|
|
- // } catch (e) {
|
|
|
- // console.error("QQ浏览器Blob URL生成失败,尝试Data URL:", e);
|
|
|
-
|
|
|
- // // Blob失败则尝试Data URL
|
|
|
- // try {
|
|
|
- // const base64 = btoa(unescape(encodeURIComponent(playlist)));
|
|
|
- // processedVideoUrl.value = `data:application/vnd.apple.mpegurl;base64,${base64}`;
|
|
|
- // console.log("QQ浏览器降级使用Data URL");
|
|
|
- // } catch (e2) {
|
|
|
- // console.error("QQ浏览器Data URL生成失败:", e2);
|
|
|
- // error.value = "QQ浏览器视频处理失败,建议使用Chrome浏览器";
|
|
|
- // emit("error", error.value);
|
|
|
- // loading.value = false;
|
|
|
- // return;
|
|
|
- // }
|
|
|
- // }
|
|
|
- // }
|
|
|
-
|
|
|
- // 其他所有浏览器(Chrome、Firefox、Edge、UC等)使用 Blob URL
|
|
|
- if (!isIOSQuarkBrowser()) {
|
|
|
- const blob = new Blob([playlist], { type: mimeType });
|
|
|
- processedVideoUrl.value = URL.createObjectURL(blob);
|
|
|
- console.log("其他浏览器使用 Blob URL");
|
|
|
- }
|
|
|
- } else {
|
|
|
- processedVideoUrl.value = url;
|
|
|
- }
|
|
|
- } else {
|
|
|
- processedVideoUrl.value = url;
|
|
|
- }
|
|
|
-
|
|
|
- await nextTick();
|
|
|
- await initVideoPlayer();
|
|
|
- emit("videoLoaded", processedVideoUrl.value);
|
|
|
- } catch (err) {
|
|
|
- error.value = err instanceof Error ? err.message : "视频处理失败";
|
|
|
- emit("error", error.value);
|
|
|
- } finally {
|
|
|
- loading.value = false;
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-// 处理 M3U8 内容
|
|
|
-const processM3u8Content = (m3u8Text: string, baseUrl: string): string => {
|
|
|
- const playlist: string[] = [];
|
|
|
- const m3u8 = m3u8Text.match(/#[^#]+/g);
|
|
|
-
|
|
|
- if (!m3u8) {
|
|
|
- throw new Error("无效的 M3U8 文件");
|
|
|
- }
|
|
|
-
|
|
|
- let processedUrl = baseUrl;
|
|
|
- if (processedUrl.includes("/play")) {
|
|
|
- processedUrl = processedUrl.replace("/play", "");
|
|
|
- }
|
|
|
-
|
|
|
- for (let i = 0; i < m3u8.length; ++i) {
|
|
|
- const line = m3u8[i].trim();
|
|
|
- if (line.startsWith("#EXTINF")) {
|
|
|
- const pattern = line.match(/#EXTINF:(\d+(?:\.\d+)?),\s*([^\n]+)/);
|
|
|
- if (!pattern) continue;
|
|
|
-
|
|
|
- const segment = /^(?!http:\/\/)/.test(pattern[2])
|
|
|
- ? line.replace(pattern[2], `${processedUrl}/${pattern[2]}`)
|
|
|
- : line;
|
|
|
- playlist.push(segment);
|
|
|
- } else {
|
|
|
- const processedLine = line.startsWith("#EXT-X-KEY")
|
|
|
- ? line.replace(/URI="([^"]+)"/, `URI="${processedUrl}/$1"`)
|
|
|
- : line;
|
|
|
- playlist.push(processedLine);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- return playlist.join("\n");
|
|
|
-};
|
|
|
-
|
|
|
-// 检测是否为 Safari 浏览器
|
|
|
-const isSafari = (): boolean => {
|
|
|
- return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
|
|
-};
|
|
|
-
|
|
|
-// 检测是否为 iOS Safari
|
|
|
-const isIOSSafari = (): boolean => {
|
|
|
- return (
|
|
|
- /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
|
|
|
- );
|
|
|
-};
|
|
|
-
|
|
|
-// 检测是否为夸克浏览器
|
|
|
-const isQuarkBrowser = (): boolean => {
|
|
|
- const ua = navigator.userAgent.toLowerCase();
|
|
|
- return ua.includes("quark") || ua.includes("quarks");
|
|
|
-};
|
|
|
-
|
|
|
-// 检测是否为 iOS 夸克浏览器
|
|
|
-const isIOSQuarkBrowser = (): boolean => {
|
|
|
- return isQuarkBrowser() && /iPad|iPhone|iPod/.test(navigator.userAgent);
|
|
|
-};
|
|
|
-
|
|
|
-// 检测是否为 QQ 浏览器
|
|
|
-const isQQBrowser = (): boolean => {
|
|
|
- const ua = navigator.userAgent.toLowerCase();
|
|
|
- return ua.includes("mqqbrowser") || ua.includes("qq/");
|
|
|
-};
|
|
|
-
|
|
|
-// 检测是否为 UC 浏览器
|
|
|
-const isUCBrowser = (): boolean => {
|
|
|
- const ua = navigator.userAgent.toLowerCase();
|
|
|
- return (
|
|
|
- ua.includes("ucbrowser") || ua.includes("ubrowser") || ua.includes("ucweb")
|
|
|
- );
|
|
|
-};
|
|
|
-
|
|
|
-// 初始化视频播放器
|
|
|
-const initVideoPlayer = async (): Promise<void> => {
|
|
|
- if (!videoElement.value || !processedVideoUrl.value) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- const video = videoElement.value;
|
|
|
- destroyHls();
|
|
|
-
|
|
|
- // 设置加载状态
|
|
|
- isVideoLoading.value = true;
|
|
|
- error.value = "";
|
|
|
-
|
|
|
- // 清除错误防抖定时器
|
|
|
- if (errorDebounceTimer) {
|
|
|
- clearTimeout(errorDebounceTimer);
|
|
|
- errorDebounceTimer = null;
|
|
|
- }
|
|
|
-
|
|
|
- // 清除之前的src,避免缓存问题
|
|
|
- if (video.src) {
|
|
|
- video.removeAttribute("src");
|
|
|
- video.load();
|
|
|
- }
|
|
|
-
|
|
|
- // iOS 夸克浏览器特殊处理(最优先)
|
|
|
- // iOS 不支持 HLS.js,且夸克的自定义播放器对 Blob/Data URL 支持有限
|
|
|
- // 直接使用原生播放器,让 iOS 处理
|
|
|
- if (isIOSQuarkBrowser()) {
|
|
|
- console.log("检测到 iOS 夸克浏览器,使用原生播放器");
|
|
|
- video.src = processedVideoUrl.value;
|
|
|
- video.load(); // 强制重新加载
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // Android 夸克浏览器特殊处理
|
|
|
- if (isQuarkBrowser()) {
|
|
|
- console.log("检测到 Android 夸克浏览器");
|
|
|
-
|
|
|
- // Android 夸克支持 HLS.js,直接使用
|
|
|
- if (Hls.isSupported()) {
|
|
|
- console.log("使用 HLS.js 播放");
|
|
|
- initHlsPlayer();
|
|
|
- } else {
|
|
|
- // 降级到原生播放
|
|
|
- console.log("HLS.js 不支持,使用原生播放器");
|
|
|
- video.src = processedVideoUrl.value;
|
|
|
- video.load(); // 强制重新加载
|
|
|
- }
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // QQ 浏览器不支持提示
|
|
|
- // if (isQQBrowser()) {
|
|
|
- // console.log("检测到 QQ 浏览器,不支持加密视频播放");
|
|
|
- // error.value =
|
|
|
- // "QQ浏览器暂不支持播放此类视频\n请使用Chrome、UC或其他浏览器访问";
|
|
|
- // // 不提供重试选项,直接返回
|
|
|
- // return;
|
|
|
- // }
|
|
|
-
|
|
|
- // UC 浏览器特殊处理 - UC通常支持标准的HLS播放
|
|
|
- if (isUCBrowser()) {
|
|
|
- console.log("检测到 UC 浏览器");
|
|
|
-
|
|
|
- // UC浏览器优先使用HLS.js,这样可以使用自定义控制条
|
|
|
- if (Hls.isSupported()) {
|
|
|
- console.log("UC浏览器使用HLS.js播放器");
|
|
|
- initHlsPlayer();
|
|
|
- } else {
|
|
|
- // 如果HLS.js不支持,使用原生播放器
|
|
|
- console.log("UC浏览器使用原生播放器");
|
|
|
- video.src = processedVideoUrl.value;
|
|
|
- video.load();
|
|
|
- }
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // Safari 特殊处理(排除夸克浏览器)
|
|
|
- if ((isSafari() || isIOSSafari()) && !isQuarkBrowser()) {
|
|
|
- // Safari 对 blob URL 的 HLS 支持有限,特别是加密流
|
|
|
- if (processedVideoUrl.value.startsWith("blob:")) {
|
|
|
- // 对于 Safari,加密的 HLS 流需要使用 HLS.js
|
|
|
- if (Hls.isSupported()) {
|
|
|
- initHlsPlayer();
|
|
|
- return;
|
|
|
- } else {
|
|
|
- error.value = "Safari不支持此加密视频格式";
|
|
|
- return;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // Safari 原生支持 HLS(非加密流)
|
|
|
- if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
|
|
- video.src = processedVideoUrl.value;
|
|
|
- video.load();
|
|
|
-
|
|
|
- // Safari 错误处理 - 使用一次性事件监听器
|
|
|
- const handleError = () => {
|
|
|
- console.log("Safari原生播放失败,尝试HLS.js");
|
|
|
- error.value = "";
|
|
|
- if (Hls.isSupported()) {
|
|
|
- initHlsPlayer();
|
|
|
- } else {
|
|
|
- error.value = "Safari 视频播放失败";
|
|
|
- }
|
|
|
- };
|
|
|
- video.addEventListener("error", handleError, { once: true });
|
|
|
-
|
|
|
- return;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 其他浏览器使用 HLS.js(性能更好)
|
|
|
- if (Hls.isSupported()) {
|
|
|
- console.log("使用 HLS.js 播放");
|
|
|
- initHlsPlayer();
|
|
|
- } else {
|
|
|
- // 降级到原生播放器
|
|
|
- console.log("HLS.js 不支持,使用原生播放器");
|
|
|
- video.src = processedVideoUrl.value;
|
|
|
- video.load();
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-// 初始化 HLS.js 播放器
|
|
|
-const initHlsPlayer = (): void => {
|
|
|
- if (!videoElement.value || !processedVideoUrl.value) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- const video = videoElement.value;
|
|
|
-
|
|
|
- const hlsConfig = {
|
|
|
- debug: false,
|
|
|
- enableWorker: true,
|
|
|
- lowLatencyMode: true,
|
|
|
- maxBufferLength: 30,
|
|
|
- maxMaxBufferLength: 60,
|
|
|
- backBufferLength: 10,
|
|
|
- // 添加更宽松的配置以提高兼容性
|
|
|
- manifestLoadingTimeOut: 10000,
|
|
|
- manifestLoadingMaxRetry: 3,
|
|
|
- levelLoadingTimeOut: 10000,
|
|
|
- levelLoadingMaxRetry: 3,
|
|
|
- };
|
|
|
-
|
|
|
- try {
|
|
|
- hlsInstance.value = new Hls(hlsConfig);
|
|
|
-
|
|
|
- hlsInstance.value.loadSource(processedVideoUrl.value);
|
|
|
- hlsInstance.value.attachMedia(video);
|
|
|
-
|
|
|
- hlsInstance.value.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
|
- console.log("HLS manifest 解析成功");
|
|
|
- error.value = "";
|
|
|
- });
|
|
|
-
|
|
|
- hlsInstance.value.on(Hls.Events.ERROR, (event, data) => {
|
|
|
- console.error("HLS 错误:", data);
|
|
|
-
|
|
|
- if (data.fatal) {
|
|
|
- switch (data.type) {
|
|
|
- case Hls.ErrorTypes.NETWORK_ERROR:
|
|
|
- console.log("网络错误,尝试恢复");
|
|
|
- if (data.details === "manifestParsingError") {
|
|
|
- error.value = "视频清单解析失败";
|
|
|
- hlsInstance.value?.destroy();
|
|
|
- hlsInstance.value = null;
|
|
|
- } else {
|
|
|
- // 尝试重新加载
|
|
|
- hlsInstance.value?.startLoad();
|
|
|
- }
|
|
|
- break;
|
|
|
- case Hls.ErrorTypes.MEDIA_ERROR:
|
|
|
- console.log("媒体错误,尝试恢复");
|
|
|
- hlsInstance.value?.recoverMediaError();
|
|
|
- break;
|
|
|
- default:
|
|
|
- console.log("其他致命错误,销毁 HLS");
|
|
|
- error.value = "视频加载失败";
|
|
|
- hlsInstance.value?.destroy();
|
|
|
- hlsInstance.value = null;
|
|
|
-
|
|
|
- // 尝试降级到原生播放器
|
|
|
- setTimeout(() => {
|
|
|
- if (videoElement.value && processedVideoUrl.value) {
|
|
|
- console.log("降级到原生播放器");
|
|
|
- error.value = "";
|
|
|
- videoElement.value.src = processedVideoUrl.value;
|
|
|
- videoElement.value.load();
|
|
|
- }
|
|
|
- }, 500);
|
|
|
- break;
|
|
|
- }
|
|
|
- }
|
|
|
- });
|
|
|
- } catch (err) {
|
|
|
- console.error("初始化 HLS.js 失败:", err);
|
|
|
- error.value = "播放器初始化失败";
|
|
|
- // 降级到原生播放器
|
|
|
- if (videoElement.value && processedVideoUrl.value) {
|
|
|
- videoElement.value.src = processedVideoUrl.value;
|
|
|
- videoElement.value.load();
|
|
|
- }
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-// 销毁 HLS 实例
|
|
|
-const destroyHls = (): void => {
|
|
|
- if (hlsInstance.value) {
|
|
|
- hlsInstance.value.destroy();
|
|
|
- hlsInstance.value = null;
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-// 重试功能
|
|
|
-const retry = (): void => {
|
|
|
- if (retryCount.value >= maxRetries) return;
|
|
|
-
|
|
|
- retryCount.value++;
|
|
|
- error.value = "";
|
|
|
- emit("retry");
|
|
|
-
|
|
|
- // 清理现有资源
|
|
|
- destroyHls();
|
|
|
- if (processedVideoUrl.value && processedVideoUrl.value.startsWith("blob:")) {
|
|
|
- URL.revokeObjectURL(processedVideoUrl.value);
|
|
|
- }
|
|
|
- if (processedVideoUrl.value && processedVideoUrl.value.startsWith("data:")) {
|
|
|
- processedVideoUrl.value = "";
|
|
|
- }
|
|
|
-
|
|
|
- // 重新处理
|
|
|
- if (props.coverUrl) {
|
|
|
- processCover(props.coverUrl);
|
|
|
- }
|
|
|
- if (props.m3u8Url) {
|
|
|
- processVideo(props.m3u8Url);
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-// 播放器控制方法
|
|
|
-const togglePlay = (): void => {
|
|
|
- if (!videoElement.value) return;
|
|
|
-
|
|
|
- if (isPlaying.value) {
|
|
|
- videoElement.value.pause();
|
|
|
- } else {
|
|
|
- videoElement.value.play();
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-const toggleMute = (): void => {
|
|
|
- if (!videoElement.value) return;
|
|
|
-
|
|
|
- isMuted.value = !isMuted.value;
|
|
|
- videoElement.value.muted = isMuted.value;
|
|
|
-};
|
|
|
-
|
|
|
-const changeVolume = (event: Event): void => {
|
|
|
- if (!videoElement.value) return;
|
|
|
-
|
|
|
- const target = event.target as HTMLInputElement;
|
|
|
- const newVolume = parseInt(target.value) / 100;
|
|
|
- volume.value = newVolume;
|
|
|
- videoElement.value.volume = newVolume;
|
|
|
-
|
|
|
- if (newVolume > 0 && isMuted.value) {
|
|
|
- isMuted.value = false;
|
|
|
- videoElement.value.muted = false;
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-const toggleFullscreen = async (): Promise<void> => {
|
|
|
- if (!videoElement.value) return;
|
|
|
-
|
|
|
- try {
|
|
|
- if (!document.fullscreenElement) {
|
|
|
- // 进入全屏
|
|
|
- const container = videoElement.value.parentElement;
|
|
|
- if (container) {
|
|
|
- // 尝试不同的全屏API
|
|
|
- if (container.requestFullscreen) {
|
|
|
- await container.requestFullscreen();
|
|
|
- } else if ((container as any).webkitRequestFullscreen) {
|
|
|
- await (container as any).webkitRequestFullscreen();
|
|
|
- } else if ((container as any).mozRequestFullScreen) {
|
|
|
- await (container as any).mozRequestFullScreen();
|
|
|
- } else if ((container as any).msRequestFullscreen) {
|
|
|
- await (container as any).msRequestFullscreen();
|
|
|
- }
|
|
|
- // iOS特殊处理
|
|
|
- else if ((videoElement.value as any).webkitEnterFullscreen) {
|
|
|
- (videoElement.value as any).webkitEnterFullscreen();
|
|
|
- }
|
|
|
- }
|
|
|
- } else {
|
|
|
- // 退出全屏
|
|
|
- if (document.exitFullscreen) {
|
|
|
- await document.exitFullscreen();
|
|
|
- } else if ((document as any).webkitExitFullscreen) {
|
|
|
- await (document as any).webkitExitFullscreen();
|
|
|
- } else if ((document as any).mozCancelFullScreen) {
|
|
|
- await (document as any).mozCancelFullScreen();
|
|
|
- } else if ((document as any).msExitFullscreen) {
|
|
|
- await (document as any).msExitFullscreen();
|
|
|
- }
|
|
|
- }
|
|
|
- } catch (err) {
|
|
|
- console.error("全屏切换失败:", err);
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-const seekVideo = (event: MouseEvent): void => {
|
|
|
- if (!videoElement.value || !duration.value) return;
|
|
|
-
|
|
|
- const progressBar = event.currentTarget as HTMLElement;
|
|
|
- const rect = progressBar.getBoundingClientRect();
|
|
|
- const pos = (event.clientX - rect.left) / rect.width;
|
|
|
- const seekTime = pos * duration.value;
|
|
|
-
|
|
|
- videoElement.value.currentTime = seekTime;
|
|
|
-};
|
|
|
-
|
|
|
-const formatTime = (seconds: number): string => {
|
|
|
- if (isNaN(seconds)) return "00:00";
|
|
|
-
|
|
|
- const mins = Math.floor(seconds / 60);
|
|
|
- const secs = Math.floor(seconds % 60);
|
|
|
- return `${mins.toString().padStart(2, "0")}:${secs
|
|
|
- .toString()
|
|
|
- .padStart(2, "0")}`;
|
|
|
-};
|
|
|
-
|
|
|
-const toggleControls = (): void => {
|
|
|
- showControls.value = true;
|
|
|
-
|
|
|
- if (controlsTimer) {
|
|
|
- clearTimeout(controlsTimer);
|
|
|
- }
|
|
|
-
|
|
|
- // 如果正在播放,3秒后自动隐藏控制条
|
|
|
- if (isPlaying.value) {
|
|
|
- controlsTimer = setTimeout(() => {
|
|
|
- showControls.value = false;
|
|
|
- }, 3000);
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-// 事件处理
|
|
|
-const handleCoverError = (event: Event): void => {
|
|
|
- const img = event.target as HTMLImageElement;
|
|
|
- if (img.src.startsWith("blob:")) {
|
|
|
- processedCoverUrl.value = props.coverUrl;
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-const handleCoverLoad = (): void => {};
|
|
|
-
|
|
|
-const onVideoLoadStart = (): void => {
|
|
|
- console.log("视频开始加载");
|
|
|
- isVideoLoading.value = true;
|
|
|
- // 清除之前的错误
|
|
|
- error.value = "";
|
|
|
-};
|
|
|
-
|
|
|
-const onVideoLoadedData = (): void => {
|
|
|
- console.log("视频数据已加载");
|
|
|
-};
|
|
|
-
|
|
|
-const onVideoCanPlay = (): void => {
|
|
|
- console.log("视频可以播放");
|
|
|
-
|
|
|
- // 清除加载状态和错误
|
|
|
- isVideoLoading.value = false;
|
|
|
- error.value = "";
|
|
|
-
|
|
|
- // 清除错误防抖定时器
|
|
|
- if (errorDebounceTimer) {
|
|
|
- clearTimeout(errorDebounceTimer);
|
|
|
- errorDebounceTimer = null;
|
|
|
- }
|
|
|
-
|
|
|
- // 发出canplay事件
|
|
|
- emit("canplay");
|
|
|
-
|
|
|
- if (props.autoPlay && videoElement.value) {
|
|
|
- videoElement.value.play().catch(() => {});
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-const onVideoPlayEvent = (): void => {
|
|
|
- isPlaying.value = true;
|
|
|
- emit("play");
|
|
|
- toggleControls(); // 开始自动隐藏倒计时
|
|
|
-};
|
|
|
-
|
|
|
-const onVideoPauseEvent = (): void => {
|
|
|
- isPlaying.value = false;
|
|
|
- showControls.value = true;
|
|
|
- if (controlsTimer) {
|
|
|
- clearTimeout(controlsTimer);
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-const onDurationChange = (): void => {
|
|
|
- if (videoElement.value) {
|
|
|
- duration.value = videoElement.value.duration;
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-const onVideoEnded = (): void => {
|
|
|
- isPlaying.value = false;
|
|
|
- showControls.value = true;
|
|
|
-};
|
|
|
-
|
|
|
-const onVideoTimeUpdate = (): void => {
|
|
|
- if (videoElement.value) {
|
|
|
- currentTime.value = videoElement.value.currentTime;
|
|
|
-
|
|
|
- // 更新进度
|
|
|
- if (duration.value > 0) {
|
|
|
- progress.value = (currentTime.value / duration.value) * 100;
|
|
|
- }
|
|
|
-
|
|
|
- // 更新缓冲进度
|
|
|
- if (videoElement.value.buffered.length > 0) {
|
|
|
- const bufferedEnd = videoElement.value.buffered.end(
|
|
|
- videoElement.value.buffered.length - 1
|
|
|
- );
|
|
|
- buffered.value = (bufferedEnd / duration.value) * 100;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- emit("timeupdate");
|
|
|
-};
|
|
|
-
|
|
|
-const onVideoSeeking = (): void => {
|
|
|
- emit("seeking");
|
|
|
-};
|
|
|
-
|
|
|
-const onVideoError = (event: Event): void => {
|
|
|
- const video = event.target as HTMLVideoElement;
|
|
|
- const errorCode = video.error?.code;
|
|
|
- const errorMessage = video.error?.message;
|
|
|
-
|
|
|
- console.log(
|
|
|
- "视频错误事件:",
|
|
|
- errorCode,
|
|
|
- errorMessage,
|
|
|
- "加载中:",
|
|
|
- isVideoLoading.value
|
|
|
- );
|
|
|
-
|
|
|
- // 清除之前的错误防抖定时器
|
|
|
- if (errorDebounceTimer) {
|
|
|
- clearTimeout(errorDebounceTimer);
|
|
|
- errorDebounceTimer = null;
|
|
|
- }
|
|
|
-
|
|
|
- // 如果视频正在加载中,延迟显示错误(给视频更多时间初始化)
|
|
|
- if (isVideoLoading.value) {
|
|
|
- console.log("视频正在初始化,延迟错误显示");
|
|
|
-
|
|
|
- // 对于夸克浏览器,给更长的初始化时间(从2s提升至3s)
|
|
|
- const delayTime = isQuarkBrowser() ? 3000 : 1000;
|
|
|
-
|
|
|
- errorDebounceTimer = setTimeout(() => {
|
|
|
- // 再次检查视频状态
|
|
|
- if (video.readyState >= 2) {
|
|
|
- // 视频已经准备好,忽略错误
|
|
|
- console.log("视频已准备好,忽略错误");
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // 显示错误
|
|
|
- showVideoError(errorCode, errorMessage);
|
|
|
- }, delayTime);
|
|
|
-
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // 如果不是加载中,立即显示错误
|
|
|
- showVideoError(errorCode, errorMessage);
|
|
|
-};
|
|
|
-
|
|
|
-// 显示视频错误的辅助函数
|
|
|
-const showVideoError = (errorCode?: number, errorMessage?: string): void => {
|
|
|
- let errorText = "视频播放失败";
|
|
|
-
|
|
|
- if (errorCode) {
|
|
|
- switch (errorCode) {
|
|
|
- case 1: // MEDIA_ERR_ABORTED
|
|
|
- errorText = "视频播放被中止";
|
|
|
- break;
|
|
|
- case 2: // MEDIA_ERR_NETWORK
|
|
|
- errorText = "网络错误,无法加载视频";
|
|
|
- break;
|
|
|
- case 3: // MEDIA_ERR_DECODE
|
|
|
- errorText = isIOSQuarkBrowser()
|
|
|
- ? "抱歉,iOS 夸克浏览器暂不支持此视频,建议使用 Safari 打开"
|
|
|
- : "视频解码失败";
|
|
|
- break;
|
|
|
- case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
|
|
|
- errorText = isIOSQuarkBrowser()
|
|
|
- ? "抱歉,iOS 夸克浏览器暂不支持此视频,建议使用 Safari 打开"
|
|
|
- : "视频格式不支持";
|
|
|
- break;
|
|
|
- default:
|
|
|
- errorText = `视频播放失败 (错误代码: ${errorCode})`;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- console.error("显示视频错误:", errorCode, errorMessage);
|
|
|
- error.value = errorText;
|
|
|
- emit("error", error.value);
|
|
|
-
|
|
|
- // Safari 原生播放失败时尝试 HLS.js(排除夸克和iOS设备)
|
|
|
- if (
|
|
|
- isSafari() &&
|
|
|
- !isQuarkBrowser() &&
|
|
|
- !isIOSSafari() &&
|
|
|
- !hlsInstance.value &&
|
|
|
- Hls.isSupported()
|
|
|
- ) {
|
|
|
- setTimeout(() => {
|
|
|
- error.value = "";
|
|
|
- initHlsPlayer();
|
|
|
- }, 1000);
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-// 监听 props 变化
|
|
|
-watch(
|
|
|
- () => props.coverUrl,
|
|
|
- (newUrl, oldUrl) => {
|
|
|
- // 只有当URL真正变化时才处理
|
|
|
- if (newUrl && newUrl !== oldUrl) {
|
|
|
- console.log("封面URL变化,重新处理封面", { newUrl, oldUrl });
|
|
|
- // 清理旧的封面URL(如果是blob)
|
|
|
- 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) => {
|
|
|
- // 如果URL发生变化,先停止当前播放
|
|
|
- if (oldUrl && newUrl !== oldUrl) {
|
|
|
- console.log("视频URL变化,停止当前播放并加载新视频", { newUrl, oldUrl });
|
|
|
- // 停止旧视频
|
|
|
- stopVideo();
|
|
|
- // 重置状态
|
|
|
- isPlaying.value = false;
|
|
|
- currentTime.value = 0;
|
|
|
- duration.value = 0;
|
|
|
- progress.value = 0;
|
|
|
- buffered.value = 0;
|
|
|
- // 处理新视频
|
|
|
- if (newUrl) {
|
|
|
- processVideo(newUrl);
|
|
|
- }
|
|
|
- } else if (newUrl) {
|
|
|
- // 初始加载
|
|
|
- processVideo(newUrl);
|
|
|
- }
|
|
|
- },
|
|
|
- { immediate: true }
|
|
|
-);
|
|
|
-
|
|
|
-// 全屏状态监听
|
|
|
-const handleFullscreenChange = (): void => {
|
|
|
- isFullscreen.value = !!(
|
|
|
- document.fullscreenElement ||
|
|
|
- (document as any).webkitFullscreenElement ||
|
|
|
- (document as any).mozFullScreenElement ||
|
|
|
- (document as any).msFullscreenElement
|
|
|
- );
|
|
|
-};
|
|
|
-
|
|
|
-// 组件挂载时添加全屏监听
|
|
|
-onMounted(() => {
|
|
|
- document.addEventListener("fullscreenchange", handleFullscreenChange);
|
|
|
- document.addEventListener("webkitfullscreenchange", handleFullscreenChange);
|
|
|
- document.addEventListener("mozfullscreenchange", handleFullscreenChange);
|
|
|
- document.addEventListener("MSFullscreenChange", handleFullscreenChange);
|
|
|
-});
|
|
|
-
|
|
|
-// 组件卸载时清理资源
|
|
|
-onUnmounted(() => {
|
|
|
- destroyHls();
|
|
|
-
|
|
|
- // 清理全屏监听
|
|
|
- document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
|
|
- document.removeEventListener(
|
|
|
- "webkitfullscreenchange",
|
|
|
- handleFullscreenChange
|
|
|
- );
|
|
|
- document.removeEventListener("mozfullscreenchange", handleFullscreenChange);
|
|
|
- document.removeEventListener("MSFullscreenChange", handleFullscreenChange);
|
|
|
-
|
|
|
- // 清理控制条定时器
|
|
|
- if (controlsTimer) {
|
|
|
- clearTimeout(controlsTimer);
|
|
|
- }
|
|
|
-
|
|
|
- // 清理错误防抖定时器
|
|
|
- if (errorDebounceTimer) {
|
|
|
- clearTimeout(errorDebounceTimer);
|
|
|
- }
|
|
|
-
|
|
|
- // 清理 Blob URL
|
|
|
- if (processedCoverUrl.value?.startsWith("blob:")) {
|
|
|
- URL.revokeObjectURL(processedCoverUrl.value);
|
|
|
- }
|
|
|
- if (processedVideoUrl.value?.startsWith("blob:")) {
|
|
|
- URL.revokeObjectURL(processedVideoUrl.value);
|
|
|
- }
|
|
|
-});
|
|
|
-
|
|
|
-// 停止HLS加载
|
|
|
-const stopHlsLoading = (): void => {
|
|
|
- if (hlsInstance.value) {
|
|
|
- hlsInstance.value.stopLoad();
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-// 停止视频播放
|
|
|
-const stopVideo = (): void => {
|
|
|
- if (videoElement.value) {
|
|
|
- videoElement.value.pause();
|
|
|
- videoElement.value.currentTime = 0;
|
|
|
-
|
|
|
- // 清除视频源
|
|
|
- videoElement.value.removeAttribute("src");
|
|
|
- videoElement.value.load();
|
|
|
- }
|
|
|
-
|
|
|
- // 停止HLS加载
|
|
|
- stopHlsLoading();
|
|
|
-
|
|
|
- // 清理HLS实例
|
|
|
- destroyHls();
|
|
|
-
|
|
|
- // 清理Blob URL
|
|
|
- if (processedVideoUrl.value?.startsWith("blob:")) {
|
|
|
- URL.revokeObjectURL(processedVideoUrl.value);
|
|
|
- processedVideoUrl.value = "";
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-// 暴露方法给父组件
|
|
|
-defineExpose({
|
|
|
- retry,
|
|
|
- stopHlsLoading,
|
|
|
- stopVideo,
|
|
|
- processedCoverUrl: computed(() => processedCoverUrl.value),
|
|
|
- processedVideoUrl: computed(() => processedVideoUrl.value),
|
|
|
- loading: computed(() => loading.value),
|
|
|
- error: computed(() => error.value),
|
|
|
-});
|
|
|
-</script>
|
|
|
-
|
|
|
-<style scoped>
|
|
|
-.video-processor {
|
|
|
- position: relative;
|
|
|
- width: 100%;
|
|
|
- height: 100%;
|
|
|
-}
|
|
|
-
|
|
|
-/* 自定义控制条 */
|
|
|
-.custom-controls {
|
|
|
- position: absolute;
|
|
|
- top: 0;
|
|
|
- left: 0;
|
|
|
- right: 0;
|
|
|
- bottom: 0;
|
|
|
- pointer-events: none;
|
|
|
- opacity: 0;
|
|
|
- transition: opacity 0.3s ease;
|
|
|
- z-index: 5;
|
|
|
-}
|
|
|
-
|
|
|
-.custom-controls.show {
|
|
|
- opacity: 1;
|
|
|
-}
|
|
|
-
|
|
|
-.custom-controls > * {
|
|
|
- pointer-events: auto;
|
|
|
-}
|
|
|
-
|
|
|
-/* 中央播放按钮 */
|
|
|
-.play-button-center {
|
|
|
- position: absolute;
|
|
|
- top: 50%;
|
|
|
- left: 50%;
|
|
|
- transform: translate(-50%, -50%);
|
|
|
- width: 80px;
|
|
|
- height: 80px;
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: center;
|
|
|
- background: rgba(0, 0, 0, 0.6);
|
|
|
- border-radius: 50%;
|
|
|
- cursor: pointer;
|
|
|
- transition: all 0.3s ease;
|
|
|
- backdrop-filter: blur(10px);
|
|
|
-}
|
|
|
-
|
|
|
-.play-button-center:hover {
|
|
|
- background: rgba(0, 0, 0, 0.8);
|
|
|
- transform: translate(-50%, -50%) scale(1.1);
|
|
|
-}
|
|
|
-
|
|
|
-/* 底部控制栏 */
|
|
|
-.controls-bottom {
|
|
|
- position: absolute;
|
|
|
- left: 0;
|
|
|
- right: 0;
|
|
|
- bottom: 0;
|
|
|
- background: linear-gradient(
|
|
|
- to top,
|
|
|
- rgba(0, 0, 0, 0.8) 0%,
|
|
|
- rgba(0, 0, 0, 0.4) 70%,
|
|
|
- transparent 100%
|
|
|
- );
|
|
|
- padding: 10px 15px;
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
- gap: 8px;
|
|
|
-}
|
|
|
-
|
|
|
-/* 进度条容器 */
|
|
|
-.progress-container {
|
|
|
- width: 100%;
|
|
|
- padding: 8px 0;
|
|
|
- cursor: pointer;
|
|
|
-}
|
|
|
-
|
|
|
-.progress-bar {
|
|
|
- position: relative;
|
|
|
- width: 100%;
|
|
|
- height: 4px;
|
|
|
- background: rgba(255, 255, 255, 0.3);
|
|
|
- border-radius: 2px;
|
|
|
- overflow: hidden;
|
|
|
-}
|
|
|
-
|
|
|
-.progress-buffered {
|
|
|
- position: absolute;
|
|
|
- top: 0;
|
|
|
- left: 0;
|
|
|
- height: 100%;
|
|
|
- background: rgba(255, 255, 255, 0.5);
|
|
|
- border-radius: 2px;
|
|
|
- transition: width 0.2s ease;
|
|
|
-}
|
|
|
-
|
|
|
-.progress-played {
|
|
|
- position: absolute;
|
|
|
- top: 0;
|
|
|
- left: 0;
|
|
|
- height: 100%;
|
|
|
- background: #3b82f6;
|
|
|
- border-radius: 2px;
|
|
|
- transition: width 0.1s ease;
|
|
|
-}
|
|
|
-
|
|
|
-/* 控制按钮区域 */
|
|
|
-.controls-buttons {
|
|
|
- display: flex;
|
|
|
- justify-content: space-between;
|
|
|
- align-items: center;
|
|
|
-}
|
|
|
-
|
|
|
-.controls-left,
|
|
|
-.controls-right {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: 12px;
|
|
|
-}
|
|
|
-
|
|
|
-.control-btn {
|
|
|
- background: none;
|
|
|
- border: none;
|
|
|
- padding: 8px;
|
|
|
- cursor: pointer;
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: center;
|
|
|
- transition: all 0.2s ease;
|
|
|
- border-radius: 4px;
|
|
|
-}
|
|
|
-
|
|
|
-.control-btn:hover {
|
|
|
- background: rgba(255, 255, 255, 0.2);
|
|
|
-}
|
|
|
-
|
|
|
-.control-btn:active {
|
|
|
- transform: scale(0.95);
|
|
|
-}
|
|
|
-
|
|
|
-/* 时间显示 */
|
|
|
-.time-display {
|
|
|
- color: white;
|
|
|
- font-size: 14px;
|
|
|
- font-weight: 500;
|
|
|
- white-space: nowrap;
|
|
|
- user-select: none;
|
|
|
-}
|
|
|
-
|
|
|
-/* 音量控制 */
|
|
|
-.volume-control {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: 8px;
|
|
|
-}
|
|
|
-
|
|
|
-.volume-slider {
|
|
|
- width: 60px;
|
|
|
- height: 4px;
|
|
|
- appearance: none;
|
|
|
- background: rgba(255, 255, 255, 0.3);
|
|
|
- border-radius: 2px;
|
|
|
- outline: none;
|
|
|
- cursor: pointer;
|
|
|
-}
|
|
|
-
|
|
|
-.volume-slider::-webkit-slider-thumb {
|
|
|
- appearance: none;
|
|
|
- width: 12px;
|
|
|
- height: 12px;
|
|
|
- background: white;
|
|
|
- border-radius: 50%;
|
|
|
- cursor: pointer;
|
|
|
-}
|
|
|
-
|
|
|
-.volume-slider::-moz-range-thumb {
|
|
|
- width: 12px;
|
|
|
- height: 12px;
|
|
|
- background: white;
|
|
|
- border-radius: 50%;
|
|
|
- border: none;
|
|
|
- cursor: pointer;
|
|
|
-}
|
|
|
-
|
|
|
-/* 移动端优化 */
|
|
|
-@media screen and (max-width: 768px) {
|
|
|
- .controls-bottom {
|
|
|
- padding: 8px 12px;
|
|
|
- }
|
|
|
-
|
|
|
- .play-button-center {
|
|
|
- width: 60px;
|
|
|
- height: 60px;
|
|
|
- }
|
|
|
-
|
|
|
- .time-display {
|
|
|
- font-size: 12px;
|
|
|
- }
|
|
|
-
|
|
|
- .control-btn {
|
|
|
- padding: 6px;
|
|
|
- }
|
|
|
-
|
|
|
- .volume-slider {
|
|
|
- display: none; /* 移动端隐藏音量滑块 */
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-/* iOS全屏样式优化 */
|
|
|
-@media screen and (max-width: 768px) {
|
|
|
- .video-processor:fullscreen .custom-controls,
|
|
|
- .video-processor:-webkit-full-screen .custom-controls,
|
|
|
- .video-processor:-moz-full-screen .custom-controls {
|
|
|
- background: rgba(0, 0, 0, 0.3);
|
|
|
- }
|
|
|
-
|
|
|
- .video-processor:fullscreen .controls-bottom,
|
|
|
- .video-processor:-webkit-full-screen .controls-bottom,
|
|
|
- .video-processor:-moz-full-screen .controls-bottom {
|
|
|
- background: linear-gradient(
|
|
|
- to top,
|
|
|
- rgba(0, 0, 0, 0.9) 0%,
|
|
|
- rgba(0, 0, 0, 0.6) 70%,
|
|
|
- transparent 100%
|
|
|
- );
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.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.7);
|
|
|
- color: white;
|
|
|
- z-index: 10;
|
|
|
-}
|
|
|
-
|
|
|
-.spinner {
|
|
|
- width: 24px;
|
|
|
- height: 24px;
|
|
|
- border: 3px solid rgba(255, 255, 255, 0.3);
|
|
|
- border-top: 3px solid white;
|
|
|
- border-radius: 50%;
|
|
|
- animation: spin 1s linear infinite;
|
|
|
- margin-bottom: 8px;
|
|
|
-}
|
|
|
-
|
|
|
-@keyframes spin {
|
|
|
- 0% {
|
|
|
- transform: rotate(0deg);
|
|
|
- }
|
|
|
- 100% {
|
|
|
- transform: rotate(360deg);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.loading-text {
|
|
|
- font-size: 14px;
|
|
|
- color: white;
|
|
|
-}
|
|
|
-
|
|
|
-.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.8);
|
|
|
- z-index: 10;
|
|
|
-}
|
|
|
-
|
|
|
-.error-content {
|
|
|
- text-align: center;
|
|
|
- color: white;
|
|
|
- padding: 20px;
|
|
|
-}
|
|
|
-
|
|
|
-.error-icon {
|
|
|
- font-size: 32px;
|
|
|
- margin-bottom: 12px;
|
|
|
-}
|
|
|
-
|
|
|
-.error-text {
|
|
|
- font-size: 14px;
|
|
|
- margin-bottom: 16px;
|
|
|
- color: #ff6b6b;
|
|
|
-}
|
|
|
-
|
|
|
-.retry-btn {
|
|
|
- padding: 8px 16px;
|
|
|
- background: #10b981;
|
|
|
- color: white;
|
|
|
- border: none;
|
|
|
- border-radius: 6px;
|
|
|
- font-size: 14px;
|
|
|
- cursor: pointer;
|
|
|
- transition: background-color 0.2s;
|
|
|
-}
|
|
|
-
|
|
|
-.retry-btn:hover {
|
|
|
- background: #059669;
|
|
|
-}
|
|
|
-</style>
|