| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727 |
- <template>
- <section class="space-y-6">
- <!-- 返回按钮 -->
- <div class="flex items-center gap-3">
- <button
- @click="goBack"
- class="flex items-center gap-2 text-white/80 hover:text-white transition"
- >
- <svg
- class="w-5 h-5"
- fill="none"
- stroke="currentColor"
- viewBox="0 0 24 24"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- stroke-width="2"
- d="M15 19l-7-7 7-7"
- />
- </svg>
- <span class="text-sm">返回</span>
- </button>
- <div class="relative">
- <button
- @click="toggleShare"
- class="p-2 rounded-lg bg-white/5 border border-white/10 text-white/70 hover:bg-white/10 hover:text-white transition"
- >
- <svg
- class="w-5 h-5"
- fill="none"
- stroke="currentColor"
- viewBox="0 0 24 24"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- stroke-width="2"
- d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.367 2.684 3 3 0 00-5.367-2.684z"
- />
- </svg>
- </button>
- <!-- 分享提示弹窗 -->
- <div
- v-if="showShareModal"
- class="absolute top-0 left-full ml-2 z-50 bg-emerald-500 text-white px-3 py-1.5 rounded-lg shadow-lg flex items-center gap-1.5 whitespace-nowrap"
- >
- <svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
- <path
- fill-rule="evenodd"
- d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
- clip-rule="evenodd"
- />
- </svg>
- <span class="text-xs font-medium">已复制</span>
- </div>
- </div>
- </div>
- <!-- 视频播放器区域 -->
- <div class="relative rounded-2xl overflow-hidden bg-black">
- <div class="aspect-video video-container">
- <video
- ref="videoPlayer"
- :poster="videoInfo.cover"
- class="w-full h-full object-contain"
- controls
- 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"
- >
- 您的浏览器不支持视频播放
- </video>
- <!-- 视频错误提示 -->
- <div
- v-if="videoError"
- class="absolute inset-0 flex items-center justify-center bg-black/80 text-white"
- >
- <div class="text-center p-6">
- <div class="text-4xl mb-4">⚠️</div>
- <h3 class="text-lg font-semibold mb-2">视频加载失败</h3>
- <p class="text-sm text-white/70 mb-4">{{ videoError }}</p>
- <button
- v-if="showRetryButton"
- @click="retryVideoLoad"
- :disabled="!canRetry"
- class="px-4 py-2 rounded-lg transition"
- :class="
- canRetry
- ? 'bg-emerald-500 text-white hover:bg-emerald-600'
- : 'bg-gray-500 text-gray-300 cursor-not-allowed'
- "
- >
- 重试
- </button>
- </div>
- </div>
- </div>
- </div>
- <!-- 视频信息区域 -->
- <div class="space-y-6">
- <!-- 视频标题和基本信息 -->
- <div class="space-y-3">
- <h1 class="text-xl font-semibold text-white leading-tight">
- {{ videoInfo.name || "视频标题" }}
- </h1>
- <div class="flex items-center gap-4 text-sm text-white/60">
- <div class="flex items-center gap-1">
- <svg
- class="w-4 h-4"
- fill="none"
- stroke="currentColor"
- viewBox="0 0 24 24"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- stroke-width="2"
- d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
- />
- </svg>
- <span>{{ formatDuration(videoInfo.duration) }}</span>
- </div>
- <div class="flex items-center gap-1">
- <svg
- class="w-4 h-4"
- fill="none"
- stroke="currentColor"
- viewBox="0 0 24 24"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- stroke-width="2"
- d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
- />
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- stroke-width="2"
- d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
- />
- </svg>
- <span>{{ formatNumber(videoInfo.view) }} 次观看</span>
- </div>
- <div class="flex items-center gap-1">
- <svg
- class="w-4 h-4"
- fill="none"
- stroke="currentColor"
- viewBox="0 0 24 24"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- stroke-width="2"
- d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
- />
- </svg>
- <span>{{ formatNumber(videoInfo.like) }} 点赞</span>
- </div>
- </div>
- </div>
- <!-- 标签信息 -->
- <div
- v-if="videoInfo.taginfo && videoInfo.taginfo.length > 0"
- class="space-y-3"
- >
- <h3 class="text-sm font-medium text-white/80">标签</h3>
- <div class="flex flex-wrap gap-2">
- <span
- v-for="tag in videoInfo.taginfo"
- :key="tag.hash"
- class="px-3 py-1.5 rounded-full bg-white/5 border border-white/10 text-xs text-white/70 hover:bg-white/10 hover:text-white transition"
- >
- {{ tag.name }}
- </span>
- </div>
- </div>
- <!-- 相关推荐 -->
- <div v-if="relatedVideos.length > 0" class="space-y-4">
- <h3 class="text-sm font-medium text-white/80">相关推荐</h3>
- <div class="grid grid-cols-2 md:grid-cols-3 gap-2">
- <article
- v-for="video in relatedVideos.slice(0, 15)"
- :key="video.id"
- @click="playVideo(video)"
- class="group rounded-xl overflow-hidden bg-white/5 border border-white/10 cursor-pointer hover:bg-white/10 transition"
- >
- <div class="aspect-[9/12] relative">
- <img
- :src="video.cover"
- :alt="video.name"
- class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
- @error="handleImageError"
- />
- <div
- class="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1.5 py-0.5 rounded"
- >
- {{ formatDuration(video.duration) }}
- </div>
- </div>
- <div class="p-2">
- <h4
- class="text-xs font-medium text-white/90 leading-tight line-clamp-2"
- >
- {{ video.name }}
- </h4>
- <p class="text-xs text-white/50 mt-0.5">
- {{ formatNumber(video.view) }} 观看
- </p>
- </div>
- </article>
- </div>
- </div>
- </div>
- </section>
- </template>
- <script setup lang="ts">
- import { ref, onMounted, onUnmounted, computed } from "vue";
- import { useRoute, useRouter } from "vue-router";
- import { searchVideoByTags } from "@/services/api";
- import Hls from "hls.js";
- // 路由相关
- const route = useRoute();
- const router = useRouter();
- // 视频播放器引用
- const videoPlayer = ref<HTMLVideoElement>();
- // HLS实例
- let hls: Hls | null = null;
- // 视频信息
- const videoInfo = ref<any>({
- id: "",
- name: "",
- cover: "",
- m3u8: "",
- duration: 0,
- view: 0,
- like: 0,
- time: 0,
- taginfo: [],
- });
- // 相关视频
- const relatedVideos = ref<any[]>([]);
- // 状态管理
- const showShareModal = ref(false);
- const videoError = ref<string>("");
- const retryCount = ref(0);
- const maxRetries = 3;
- const lastRetryTime = ref(0);
- const retryCooldown = 3000; // 3秒冷却时间
- const forceUpdate = ref(0); // 强制更新触发器
- // 生成设备标识
- const generateMacAddress = (): string => {
- const hex = "0123456789ABCDEF";
- let mac = "";
- for (let i = 0; i < 6; i++) {
- if (i > 0) mac += ":";
- mac += hex[Math.floor(Math.random() * 16)];
- mac += hex[Math.floor(Math.random() * 16)];
- }
- return mac;
- };
- const device = generateMacAddress();
- // 计算属性
- const canRetry = computed(() => {
- forceUpdate.value; // 依赖forceUpdate来触发重新计算
- const now = Date.now();
- const isCooldownActive = now - lastRetryTime.value < retryCooldown;
- const hasRetriesLeft = retryCount.value < maxRetries;
- return !isCooldownActive && hasRetriesLeft;
- });
- const showRetryButton = computed(() => {
- return retryCount.value < maxRetries;
- });
- // 格式化时长
- const formatDuration = (duration: string | number): string => {
- const seconds = parseInt(String(duration));
- const minutes = Math.floor(seconds / 60);
- const remainingSeconds = seconds % 60;
- return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
- };
- // 格式化数字
- const formatNumber = (num: string | number): string => {
- const n = parseInt(String(num));
- if (n >= 10000) {
- return `${(n / 10000).toFixed(1)}万`;
- }
- return n.toString();
- };
- // 处理图片加载错误
- const handleImageError = (event: Event) => {
- const img = event.target as HTMLImageElement;
- img.src =
- "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgdmlld0JveD0iMCAwIDIwMCAyMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMjAwIiBmaWxsPSIjMzMzIi8+CjxwYXRoIGQ9Ik0xMDAgNzVMMTI1IDEwMEgxMDBWMTI1SDc1TDEwMCAxMDBaIiBmaWxsPSIjNjY2Ii8+Cjwvc3ZnPgo=";
- };
- // 视频播放器事件处理
- const onVideoLoadStart = () => {
- console.log("视频开始加载...");
- };
- const onVideoLoadedData = () => {
- console.log("视频数据加载完成");
- };
- const onVideoCanPlay = () => {
- console.log("视频可以播放");
- };
- const onVideoError = (event: Event) => {
- const video = event.target as HTMLVideoElement;
- console.error("视频播放错误:", video.error);
- console.error("错误详情:", {
- code: video.error?.code,
- message: video.error?.message,
- networkState: video.networkState,
- readyState: video.readyState,
- });
- // 设置统一的错误信息
- videoError.value = "视频加载失败";
- };
- // 重试视频加载
- const retryVideoLoad = () => {
- const now = Date.now();
- // 检查是否在冷却时间内
- if (now - lastRetryTime.value < retryCooldown) {
- const remainingTime = Math.ceil(
- (retryCooldown - (now - lastRetryTime.value)) / 1000
- );
- console.log(`重试冷却中,还需等待 ${remainingTime} 秒`);
- return;
- }
- // 检查是否超过最大重试次数
- if (retryCount.value >= maxRetries) {
- console.log("已达到最大重试次数");
- return;
- }
- // 更新重试状态
- retryCount.value++;
- lastRetryTime.value = now;
- console.log(`第 ${retryCount.value} 次重试`);
- videoError.value = "";
- destroyHls();
- // 延迟重新初始化
- setTimeout(() => {
- initHlsPlayer();
- }, 500);
- };
- // 初始化HLS播放器
- const initHlsPlayer = () => {
- if (!videoPlayer.value) return;
- const video = videoPlayer.value;
- const videoSrc = videoInfo.value.m3u8;
- if (!videoSrc) {
- console.error("没有视频源地址");
- return;
- }
- // console.log("初始化HLS播放器,视频源:", videoSrc);
- console.log("初始化HLS播放器");
- // 检查浏览器是否原生支持HLS
- if (video.canPlayType("application/vnd.apple.mpegurl")) {
- // Safari原生支持HLS
- console.log("使用原生HLS支持");
- video.src = videoSrc;
- } else if (Hls.isSupported()) {
- // 使用HLS.js
- console.log("使用HLS.js支持");
- // 先检测URL是否为HLS格式
- detectVideoFormat(videoSrc)
- .then((format) => {
- if (format === "hls") {
- initHlsJsPlayer(videoSrc, video);
- } else {
- console.log("检测到非HLS格式,尝试直接播放");
- video.src = videoSrc;
- }
- })
- .catch((error) => {
- console.error("格式检测失败,尝试直接播放:", error);
- video.src = videoSrc;
- });
- } else {
- console.error("浏览器不支持HLS播放");
- video.src = videoSrc;
- }
- };
- // 检测视频格式
- const detectVideoFormat = async (url: string): Promise<"hls" | "direct"> => {
- try {
- const response = await fetch(url, {
- method: "HEAD",
- mode: "cors",
- });
- const contentType = response.headers.get("content-type") || "";
- console.log("Content-Type:", contentType);
- // 检查是否为HLS格式
- if (
- contentType.includes("application/vnd.apple.mpegurl") ||
- contentType.includes("application/x-mpegURL") ||
- url.includes(".m3u8")
- ) {
- return "hls";
- }
- // 检查是否为直接视频文件
- if (contentType.includes("video/")) {
- return "direct";
- }
- // 如果无法确定,尝试获取前几个字节来判断
- const textResponse = await fetch(url, {
- method: "GET",
- mode: "cors",
- headers: { Range: "bytes=0-1023" },
- });
- const text = await textResponse.text();
- if (text.startsWith("#EXTM3U")) {
- return "hls";
- }
- return "direct";
- } catch (error) {
- console.error("格式检测失败:", error);
- return "direct";
- }
- };
- // 初始化HLS.js播放器
- const initHlsJsPlayer = (videoSrc: string, video: HTMLVideoElement) => {
- hls = new Hls({
- debug: false, // 关闭调试日志
- enableWorker: true,
- lowLatencyMode: true,
- });
- hls.loadSource(videoSrc);
- hls.attachMedia(video);
- hls.on(Hls.Events.MANIFEST_PARSED, () => {
- console.log("HLS清单解析完成,可以播放");
- });
- hls.on(Hls.Events.ERROR, (event, data) => {
- console.error("HLS错误:", data);
- if (data.fatal) {
- switch (data.type) {
- case Hls.ErrorTypes.NETWORK_ERROR:
- console.error("网络错误,尝试恢复...");
- if (data.details === "manifestParsingError") {
- videoError.value = "视频加载失败";
- // 销毁HLS实例,尝试直接播放
- hls?.destroy();
- hls = null;
- video.src = videoSrc;
- } else {
- hls?.startLoad();
- }
- break;
- case Hls.ErrorTypes.MEDIA_ERROR:
- console.error("媒体错误,尝试恢复...");
- hls?.recoverMediaError();
- break;
- default:
- console.error("无法恢复的错误,尝试直接播放");
- videoError.value = "视频加载失败";
- // 销毁HLS实例,尝试直接播放
- hls?.destroy();
- hls = null;
- video.src = videoSrc;
- break;
- }
- }
- });
- };
- // 清理HLS实例
- const destroyHls = () => {
- if (hls) {
- console.log("清理HLS实例");
- hls.destroy();
- hls = null;
- }
- };
- // 返回上一页
- const goBack = () => {
- router.back();
- };
- // 分享视频(直接复制链接并显示提示)
- const toggleShare = async () => {
- try {
- const videoUrl = window.location.href;
- await navigator.clipboard.writeText(videoUrl);
- showShareModal.value = true;
- // 1秒后自动隐藏提示
- setTimeout(() => {
- showShareModal.value = false;
- }, 1000);
- } catch (error) {
- console.error("复制链接失败:", error);
- }
- };
- // 播放视频
- const playVideo = (video: any) => {
- router.push({
- name: "VideoPlayer",
- params: { id: video.id },
- query: {
- name: video.name,
- cover: video.cover,
- m3u8: video.m3u8,
- duration: video.duration,
- view: video.view,
- like: video.like,
- time: video.time,
- taginfo: JSON.stringify(video.taginfo || []),
- },
- });
- };
- // 加载视频信息
- const loadVideoInfo = () => {
- // 优先从路由参数获取ID
- const videoId = route.params.id || route.query.id;
- // 从查询参数获取其他信息
- const videoData = route.query;
- if (videoId) {
- videoInfo.value = {
- id: videoId,
- name: videoData.name || `视频 ${videoId}`,
- cover: videoData.cover || "",
- m3u8: "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8",
- duration: videoData.duration || 0,
- view: videoData.view || 0,
- like: videoData.like || 0,
- time: videoData.time || 0,
- taginfo: videoData.taginfo ? JSON.parse(videoData.taginfo as string) : [],
- };
- // 设置页面标题
- document.title = `${videoInfo.value.name} - 视频播放`;
- // console.log("加载视频信息:", videoInfo.value);
- } else {
- console.error("未找到视频ID");
- }
- };
- // 加载相关视频
- const loadRelatedVideos = async () => {
- try {
- // 获取当前视频的标签
- const currentVideoTags = videoInfo.value.taginfo || [];
- if (currentVideoTags.length > 0) {
- // 随机选择一个标签
- const randomTag =
- currentVideoTags[Math.floor(Math.random() * currentVideoTags.length)];
- // 生成1-10之间的随机页码
- const randomPage = Math.floor(Math.random() * 10) + 1;
- // 获取15个该标签下的最热视频
- const response = await searchVideoByTags(
- device,
- randomPage,
- 15,
- randomTag.hash,
- "long",
- "view"
- );
- if (response.status === 0 && response.data?.list) {
- // 过滤掉当前视频
- relatedVideos.value = response.data.list
- .filter((video: any) => video.id !== videoInfo.value.id)
- .slice(0, 15);
- }
- } else {
- // 如果没有标签,则获取全局最热视频
- const randomPage = Math.floor(Math.random() * 10) + 1;
- const response = await searchVideoByTags(
- device,
- randomPage,
- 15,
- undefined,
- "long",
- "view"
- );
- if (response.status === 0 && response.data?.list) {
- relatedVideos.value = response.data.list
- .filter((video: any) => video.id !== videoInfo.value.id)
- .slice(0, 15);
- }
- }
- } catch (error) {
- console.error("加载相关视频失败:", error);
- }
- };
- onMounted(() => {
- loadVideoInfo();
- loadRelatedVideos();
- // 延迟初始化HLS播放器,确保DOM已渲染
- setTimeout(() => {
- initHlsPlayer();
- }, 100);
- // 启动定时器更新按钮状态
- const timer = setInterval(() => {
- if (videoError.value && retryCount.value < maxRetries) {
- forceUpdate.value++; // 触发计算属性重新计算
- }
- }, 1000);
- // 组件卸载时清除定时器
- onUnmounted(() => {
- clearInterval(timer);
- });
- });
- onUnmounted(() => {
- destroyHls();
- });
- </script>
- <style scoped>
- /* 视频容器样式 */
- .video-container {
- position: relative;
- }
- /* 移动端全屏样式 */
- @media screen and (max-width: 768px) {
- video {
- /* 强制横屏全屏 */
- object-fit: contain;
- }
- /* 全屏时的样式 */
- video:fullscreen {
- width: 100vw;
- height: 100vh;
- object-fit: contain;
- background: black;
- }
- /* WebKit全屏样式 */
- video:-webkit-full-screen {
- width: 100vw;
- height: 100vh;
- object-fit: contain;
- background: black;
- }
- /* Mozilla全屏样式 */
- video:-moz-full-screen {
- width: 100vw;
- height: 100vh;
- object-fit: contain;
- background: black;
- }
- /* MS全屏样式 */
- video:-ms-fullscreen {
- width: 100vw;
- height: 100vh;
- object-fit: contain;
- background: black;
- }
- }
- </style>
|