hookehuyr

fix(video-player): 修复视频播放器条件渲染和清晰度插件问题

修复 v-show 改为 v-if 避免视频播放器隐藏时继续消耗资源
改进视频类型推断逻辑,支持从 videoId 扩展名识别类型
优化清晰度插件初始化逻辑,确保在 m3u8 源下正确工作
增强原生 HLS 播放能力检测,避免不必要的 VHS 覆盖
...@@ -40,6 +40,7 @@ const createPlayerStub = ({ bandwidthBitsPerSecond, bandwidthOn = 'vhs' } = {}) ...@@ -40,6 +40,7 @@ const createPlayerStub = ({ bandwidthBitsPerSecond, bandwidthOn = 'vhs' } = {})
40 }, 40 },
41 tech: () => techObject, 41 tech: () => techObject,
42 hlsQualitySelector: vi.fn(), 42 hlsQualitySelector: vi.fn(),
43 + qualityLevels: vi.fn(() => ({ on: vi.fn() })),
43 error: () => null, 44 error: () => null,
44 load: vi.fn(), 45 load: vi.fn(),
45 pause: vi.fn(), 46 pause: vi.fn(),
...@@ -158,3 +159,111 @@ describe('useVideoPlayer HLS 下载速度调试', () => { ...@@ -158,3 +159,111 @@ describe('useVideoPlayer HLS 下载速度调试', () => {
158 expect(hlsSpeedDebugText.value).toContain('tech:') 159 expect(hlsSpeedDebugText.value).toContain('tech:')
159 }) 160 })
160 }) 161 })
162 +
163 +describe('useVideoPlayer 清晰度插件兼容', () => {
164 + let console_warn_spy
165 +
166 + beforeEach(() => {
167 + console_warn_spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
168 + })
169 +
170 + afterEach(() => {
171 + console_warn_spy?.mockRestore?.()
172 + })
173 +
174 + it('非 m3u8 源不会初始化清晰度插件', () => {
175 + const props = {
176 + options: {},
177 + videoUrl: 'https://example.com/test.mp4',
178 + videoId: 'v1',
179 + autoplay: false,
180 + debug: false,
181 + useNativeOnIos: true
182 + }
183 +
184 + const emit = vi.fn()
185 + const videoRef = ref(null)
186 + const nativeVideoRef = ref(null)
187 +
188 + const { handleVideoJsMounted } = useVideoPlayer(
189 + props,
190 + emit,
191 + videoRef,
192 + nativeVideoRef
193 + )
194 +
195 + const { player } = createPlayerStub({ bandwidthBitsPerSecond: 8 * 1024 * 1024, bandwidthOn: 'vhs' })
196 +
197 + handleVideoJsMounted({ state: {}, player })
198 +
199 + expect(player.hlsQualitySelector).not.toHaveBeenCalled()
200 + })
201 +
202 + it('m3u8 + vhs tech 下会别名 hls 并初始化清晰度插件', () => {
203 + const props = {
204 + options: {},
205 + videoUrl: 'https://example.com/test.m3u8',
206 + videoId: 'v1',
207 + autoplay: false,
208 + debug: false,
209 + useNativeOnIos: true
210 + }
211 +
212 + const emit = vi.fn()
213 + const videoRef = ref(null)
214 + const nativeVideoRef = ref(null)
215 +
216 + const { handleVideoJsMounted } = useVideoPlayer(
217 + props,
218 + emit,
219 + videoRef,
220 + nativeVideoRef
221 + )
222 +
223 + const { player } = createPlayerStub({ bandwidthBitsPerSecond: 8 * 1024 * 1024, bandwidthOn: 'vhs' })
224 +
225 + expect(player.tech().hls).toBeUndefined()
226 + expect(player.tech().vhs).toBeTruthy()
227 +
228 + handleVideoJsMounted({ state: {}, player })
229 +
230 + expect(player.tech().hls).toBe(player.tech().vhs)
231 + expect(player.hlsQualitySelector).toHaveBeenCalledTimes(1)
232 + })
233 +})
234 +
235 +describe('useVideoPlayer blob URL 兜底类型识别', () => {
236 + let console_warn_spy
237 +
238 + beforeEach(() => {
239 + console_warn_spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
240 + })
241 +
242 + afterEach(() => {
243 + console_warn_spy?.mockRestore?.()
244 + })
245 +
246 + it('blob: URL 时可从 videoId 的扩展名推断 type', () => {
247 + const props = {
248 + options: {},
249 + videoUrl: 'blob:http://localhost:8206/41e9655f-37ee-4e48-bfd2-29104d64d7b3',
250 + videoId: '368_1726304830.mp4',
251 + autoplay: false,
252 + debug: false,
253 + useNativeOnIos: true
254 + }
255 +
256 + const emit = vi.fn()
257 + const videoRef = ref(null)
258 + const nativeVideoRef = ref(null)
259 +
260 + const { videoOptions } = useVideoPlayer(
261 + props,
262 + emit,
263 + videoRef,
264 + nativeVideoRef
265 + )
266 +
267 + expect(videoOptions.value.sources[0].type).toBe('video/mp4')
268 + })
269 +})
......
...@@ -100,17 +100,45 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -100,17 +100,45 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
100 }); 100 });
101 101
102 // 4. 视频类型判断 102 // 4. 视频类型判断
103 + const getUrlPathExtension = (url) => {
104 + const urlText = (url || "").trim();
105 + if (!urlText) return "";
106 + try {
107 + const u = typeof window !== "undefined" ? new URL(urlText, window.location?.href) : null;
108 + const pathname = u ? u.pathname : urlText;
109 + const lastDot = pathname.lastIndexOf(".");
110 + if (lastDot < 0) return "";
111 + return pathname.slice(lastDot + 1).toLowerCase();
112 + } catch (e) {
113 + const withoutQuery = urlText.split("?")[0].split("#")[0];
114 + const lastDot = withoutQuery.lastIndexOf(".");
115 + if (lastDot < 0) return "";
116 + return withoutQuery.slice(lastDot + 1).toLowerCase();
117 + }
118 + };
119 +
103 const getVideoMimeType = (url) => { 120 const getVideoMimeType = (url) => {
104 const urlText = (url || "").toLowerCase(); 121 const urlText = (url || "").toLowerCase();
105 if (urlText.includes(".m3u8")) return "application/x-mpegURL"; 122 if (urlText.includes(".m3u8")) return "application/x-mpegURL";
106 - if (urlText.includes(".mp4")) return "video/mp4"; 123 + const ext = getUrlPathExtension(urlText) || getUrlPathExtension(props?.videoId);
107 - if (urlText.includes(".mov")) return "video/quicktime"; 124 + if (ext === "m3u8") return "application/x-mpegURL";
125 + if (ext === "mp4" || ext === "m4v") return "video/mp4";
126 + if (ext === "mov") return "video/quicktime";
127 + if (ext === "webm") return "video/webm";
128 + if (ext === "ogv" || ext === "ogg") return "video/ogg";
108 return ""; 129 return "";
109 }; 130 };
110 131
111 // 5. 视频源配置 132 // 5. 视频源配置
112 const videoSources = computed(() => { 133 const videoSources = computed(() => {
113 - const type = getVideoMimeType(videoUrlValue.value); 134 + const inferredType = getVideoMimeType(videoUrlValue.value);
135 + const probeType = (probeInfo.value.content_type || "").toLowerCase();
136 + const type = inferredType
137 + || (probeType.includes("application/vnd.apple.mpegurl") || probeType.includes("application/x-mpegurl") ? "application/x-mpegURL" : "")
138 + || (probeType.includes("video/mp4") ? "video/mp4" : "")
139 + || (probeType.includes("video/quicktime") ? "video/quicktime" : "")
140 + || (probeType.includes("video/webm") ? "video/webm" : "")
141 + || (probeType.includes("video/ogg") ? "video/ogg" : "");
114 if (type) { 142 if (type) {
115 return [{ src: videoUrlValue.value, type }]; 143 return [{ src: videoUrlValue.value, type }];
116 } 144 }
...@@ -143,69 +171,87 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -143,69 +171,87 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
143 return ""; 171 return "";
144 }; 172 };
145 173
174 + const canProbeVideoUrl = (url) => {
175 + const urlText = (url || "").trim();
176 + if (!urlText) return false;
177 + if (typeof window === "undefined" || typeof window.location === "undefined") return false;
178 + if (typeof fetch === "undefined") return false;
179 + if (/^(blob:|data:)/i.test(urlText)) return false;
180 + try {
181 + const u = new URL(urlText, window.location.href);
182 + if (!u.protocol || !u.origin) return false;
183 + if (u.origin !== window.location.origin) return false;
184 + return true;
185 + } catch (e) {
186 + return false;
187 + }
188 + };
189 +
146 // 资源探测 190 // 资源探测
147 const probeVideo = async () => { 191 const probeVideo = async () => {
148 const url = videoUrlValue.value; 192 const url = videoUrlValue.value;
149 - if (!url || typeof fetch === "undefined") return; 193 + if (!canProbeVideoUrl(url)) return;
150 if (probeLoading.value) return; 194 if (probeLoading.value) return;
151 195
152 probeLoading.value = true; 196 probeLoading.value = true;
153 const controller = typeof AbortController !== "undefined" ? new AbortController() : null; 197 const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
154 const timeoutId = setTimeout(() => controller?.abort?.(), 8000); 198 const timeoutId = setTimeout(() => controller?.abort?.(), 8000);
199 + let controller2 = null;
200 + let timeoutId2 = null;
155 201
156 try { 202 try {
157 - const headRes = await fetch(url, { 203 + try {
158 - method: "HEAD", 204 + const headRes = await fetch(url, {
159 - mode: "cors", 205 + method: "HEAD",
160 - cache: "no-store", 206 + mode: "cors",
161 - signal: controller?.signal, 207 + cache: "no-store",
162 - }); 208 + signal: controller?.signal,
209 + });
163 210
164 - const contentLength = headRes.headers.get("content-length"); 211 + const contentLength = headRes.headers.get("content-length");
165 - probeInfo.value = { 212 + probeInfo.value = {
166 - ok: headRes.ok, 213 + ok: headRes.ok,
167 - status: headRes.status, 214 + status: headRes.status,
168 - content_type: headRes.headers.get("content-type") || "", 215 + content_type: headRes.headers.get("content-type") || "",
169 - content_length: contentLength ? Number(contentLength) || null : null, 216 + content_length: contentLength ? Number(contentLength) || null : null,
170 - accept_ranges: headRes.headers.get("accept-ranges") || "", 217 + accept_ranges: headRes.headers.get("accept-ranges") || "",
171 - }; 218 + };
219 +
220 + if (headRes.ok && probeInfo.value.content_length) return;
221 + } catch (e) {
222 + // 忽略 HEAD 请求失败
223 + }
172 224
173 - if (headRes.ok && probeInfo.value.content_length) return; 225 + controller2 = typeof AbortController !== "undefined" ? new AbortController() : null;
174 - } catch (e) { 226 + timeoutId2 = setTimeout(() => controller2?.abort?.(), 8000);
175 - // 忽略 HEAD 请求失败 227 + try {
228 + const rangeRes = await fetch(url, {
229 + method: "GET",
230 + mode: "cors",
231 + cache: "no-store",
232 + headers: { Range: "bytes=0-1" },
233 + signal: controller2?.signal,
234 + });
235 + const contentRange = rangeRes.headers.get("content-range") || "";
236 + const match = contentRange.match(/\/(\d+)\s*$/);
237 + const total = match ? Number(match[1]) || null : null;
238 + const contentLength = rangeRes.headers.get("content-length");
239 +
240 + probeInfo.value = {
241 + ok: rangeRes.ok,
242 + status: rangeRes.status,
243 + content_type: rangeRes.headers.get("content-type") || "",
244 + content_length: total || (contentLength ? Number(contentLength) || null : null),
245 + accept_ranges: rangeRes.headers.get("accept-ranges") || "",
246 + };
247 + } catch (e) {
248 + // 忽略错误
249 + }
176 } finally { 250 } finally {
251 + if (timeoutId2) clearTimeout(timeoutId2);
177 clearTimeout(timeoutId); 252 clearTimeout(timeoutId);
178 probeLoading.value = false; 253 probeLoading.value = false;
179 } 254 }
180 -
181 - // 如果 HEAD 失败,尝试 GET Range 0-1
182 - const controller2 = typeof AbortController !== "undefined" ? new AbortController() : null;
183 - const timeoutId2 = setTimeout(() => controller2?.abort?.(), 8000);
184 - try {
185 - const rangeRes = await fetch(url, {
186 - method: "GET",
187 - mode: "cors",
188 - cache: "no-store",
189 - headers: { Range: "bytes=0-1" },
190 - signal: controller2?.signal,
191 - });
192 - const contentRange = rangeRes.headers.get("content-range") || "";
193 - const match = contentRange.match(/\/(\d+)\s*$/);
194 - const total = match ? Number(match[1]) || null : null;
195 - const contentLength = rangeRes.headers.get("content-length");
196 -
197 - probeInfo.value = {
198 - ok: rangeRes.ok,
199 - status: rangeRes.status,
200 - content_type: rangeRes.headers.get("content-type") || "",
201 - content_length: total || (contentLength ? Number(contentLength) || null : null),
202 - accept_ranges: rangeRes.headers.get("accept-ranges") || "",
203 - };
204 - } catch (e) {
205 - // 忽略错误
206 - } finally {
207 - clearTimeout(timeoutId2);
208 - }
209 }; 255 };
210 256
211 // 7. 错误处理逻辑 257 // 7. 错误处理逻辑
...@@ -468,6 +514,21 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -468,6 +514,21 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
468 }; 514 };
469 515
470 // 5. Video.js 播放器逻辑 (PC/Android) 516 // 5. Video.js 播放器逻辑 (PC/Android)
517 + const canPlayHlsNatively = () => {
518 + if (typeof document === "undefined") return false;
519 + const el = document.createElement("video");
520 + if (!el || typeof el.canPlayType !== "function") return false;
521 + const r1 = el.canPlayType("application/vnd.apple.mpegurl");
522 + const r2 = el.canPlayType("application/x-mpegURL");
523 + return r1 === "probably" || r1 === "maybe" || r2 === "probably" || r2 === "maybe";
524 + };
525 +
526 + const shouldOverrideNativeHls = computed(() => {
527 + if (!isM3U8.value) return false;
528 + if (videojs.browser.IS_SAFARI) return false;
529 + return !canPlayHlsNatively();
530 + });
531 +
471 const videoOptions = computed(() => ({ 532 const videoOptions = computed(() => ({
472 controls: true, 533 controls: true,
473 preload: "metadata", 534 preload: "metadata",
...@@ -478,7 +539,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -478,7 +539,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
478 sources: videoSources.value, 539 sources: videoSources.value,
479 html5: { 540 html5: {
480 vhs: { 541 vhs: {
481 - overrideNative: !videojs.browser.IS_SAFARI, // 非 Safari 下使用 VHS 解析 HLS 542 + overrideNative: shouldOverrideNativeHls.value,
482 }, 543 },
483 nativeVideoTracks: false, 544 nativeVideoTracks: false,
484 nativeAudioTracks: false, 545 nativeAudioTracks: false,
...@@ -513,12 +574,42 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -513,12 +574,42 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
513 if (player.value) { 574 if (player.value) {
514 setHlsDebug('mounted'); 575 setHlsDebug('mounted');
515 576
516 - // 初始化多码率切换插件 (七牛云多码率支持) 577 + const quality_selector_inited = { value: false };
517 - if (player.value.hlsQualitySelector) { 578 + const setupQualitySelector = () => {
518 - player.value.hlsQualitySelector({ 579 + if (quality_selector_inited.value) return;
519 - displayCurrentQuality: true, 580 + if (!isM3U8.value) return;
520 - }); 581 + const p = player.value;
521 - } 582 + if (!p || (typeof p.isDisposed === "function" && p.isDisposed())) return;
583 + if (typeof p.hlsQualitySelector !== "function") return;
584 + if (typeof p.qualityLevels !== "function") return;
585 +
586 + let tech = null;
587 + try {
588 + tech = typeof p.tech === "function" ? p.tech({ IWillNotUseThisInPlugins: true }) : null;
589 + } catch (e) {
590 + tech = null;
591 + }
592 + if (!tech) return;
593 + if (!tech.hls && tech.vhs) {
594 + try {
595 + tech.hls = tech.vhs;
596 + } catch (e) {
597 + void e;
598 + }
599 + }
600 + if (!tech.hls) return;
601 +
602 + try {
603 + p.hlsQualitySelector({
604 + displayCurrentQuality: true,
605 + });
606 + quality_selector_inited.value = true;
607 + } catch (e) {
608 + void e;
609 + }
610 + };
611 +
612 + setupQualitySelector();
522 613
523 player.value.on('error', () => { 614 player.value.on('error', () => {
524 const err = player.value.error(); 615 const err = player.value.error();
...@@ -527,11 +618,13 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -527,11 +618,13 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
527 618
528 player.value.on('loadstart', () => { 619 player.value.on('loadstart', () => {
529 showErrorOverlay.value = false; 620 showErrorOverlay.value = false;
621 + setupQualitySelector();
530 }); 622 });
531 623
532 player.value.on('canplay', () => { 624 player.value.on('canplay', () => {
533 showErrorOverlay.value = false; 625 showErrorOverlay.value = false;
534 retryCount.value = 0; 626 retryCount.value = 0;
627 + setupQualitySelector();
535 }); 628 });
536 629
537 player.value.on('play', () => { 630 player.value.on('play', () => {
......
...@@ -158,7 +158,7 @@ ...@@ -158,7 +158,7 @@
158 </div> 158 </div>
159 </div> 159 </div>
160 <!-- 视频播放器 --> 160 <!-- 视频播放器 -->
161 - <VideoPlayer v-show="isVideoPlaying" ref="videoPlayerRef" :video-url="videoUrl" 161 + <VideoPlayer v-if="isVideoPlaying" ref="videoPlayerRef" :video-url="videoUrl"
162 :video-id="videoTitle" :use-native-on-ios="false" :autoplay="false" class="w-full h-full" @play="handleVideoPlay" 162 :video-id="videoTitle" :use-native-on-ios="false" :autoplay="false" class="w-full h-full" @play="handleVideoPlay"
163 @pause="handleVideoPause" /> 163 @pause="handleVideoPause" />
164 </div> 164 </div>
......