From f12da66da9190f1981b8c0d19bf83c5bccfc34ca Mon Sep 17 00:00:00 2001 From: mtvpls <247332661+mtvpls@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:37:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=87=AA=E5=8A=A8=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E5=88=A4=E6=96=AD=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/play/page.tsx | 491 +++++++++++++++++++++++++++++++----------- 1 file changed, 370 insertions(+), 121 deletions(-) diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 423b51b..4e58df4 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -1548,6 +1548,8 @@ function PlayPageClient() { const refreshTimerRef = useRef(null); // 14分钟定时器 const currentXiaoyaUrlRef = useRef(''); // 当前xiaoya原始URL(用于刷新) const isInitialLoadRef = useRef(true); // 标记是否为首次加载 + // xiaoya 仅 m3u8 可续期;openlist 由 refresh14m 决定。用于 startRefreshTimer 自身兜底校验 + const linkRefreshEligibleRef = useRef(false); const suppressPlayRecordJumpOnNextEpisodeChangeRef = useRef(false); // 主动切集时不显示播放记录跳转提示 // 视频源代理模式状态 @@ -2864,15 +2866,100 @@ function PlayPageClient() { }; /** - * 刷新xiaoya链接(静默刷新,不改变videoUrl状态) - * @param hls HLS实例 - * @param video 视频元素 - * @param isScheduled 是否为定时刷新(true=定时,false=错误触发) + * 判断当前是否应启用 14 分钟链接续期 + * xiaoya:始终启用;openlist:PathMeta.refresh14m 为 true + */ + const shouldEnableLinkRefresh = () => { + const lazyUrl = currentXiaoyaUrlRef.current; + if (!lazyUrl) return false; + + if (lazyUrl.startsWith('/api/xiaoya/play')) { + return true; + } + + if (lazyUrl.startsWith('/api/openlist/play')) { + return Boolean(detailRef.current?.refresh14m); + } + + return false; + }; + + /** + * 是否像 HLS/m3u8 播放地址(xiaoya 仍只在此场景启 14 分钟定时器) + */ + const isM3u8LikePlayUrl = (url: string) => { + if (!url) return false; + const lower = url.toLowerCase(); + return ( + lower.includes('.m3u8') || + lower.includes('.m3u') || + lower.includes('/m3u8/') || + lower.includes('m3u8') + ); + }; + + /** + * 是否应在拿到真实地址后启动 14 分钟定时器 + * - xiaoya:保持原逻辑,仅 m3u8 启 + * - openlist:PathMeta.refresh14m 开启即可(签名 URL 常无扩展名) + */ + const shouldStartLinkRefreshTimer = (resolvedPlayUrl?: string) => { + if (!shouldEnableLinkRefresh()) { + linkRefreshEligibleRef.current = false; + return false; + } + const lazyUrl = currentXiaoyaUrlRef.current; + // openlist:PathMeta.refresh14m 已通过 shouldEnableLinkRefresh;签名 URL 常无扩展名 + if (lazyUrl?.startsWith('/api/openlist/play')) { + linkRefreshEligibleRef.current = true; + return true; + } + // xiaoya:保持原逻辑,仅 m3u8 启定时器 + const ok = isM3u8LikePlayUrl(resolvedPlayUrl || ''); + linkRefreshEligibleRef.current = ok; + return ok; + }; + + /** + * 获取当前播放用的 video / hls 实例(定时器不依赖创建时闭包) + */ + const getPlaybackMedia = (preferredHls?: any, preferredVideo?: HTMLVideoElement) => { + const video = + preferredVideo || + (artPlayerRef.current?.video as HTMLVideoElement | undefined) || + null; + const hls = preferredHls || (video as any)?.hls || null; + return { video, hls }; + }; + + /** + * 刷新成功后的统一收尾:提示、重置重试、重启定时器 + */ + const finishLinkRefresh = ( + isScheduled: boolean, + preferredHls?: any, + preferredVideo?: HTMLVideoElement + ) => { + setIsRefreshingUrl(false); + if (artPlayerRef.current) { + artPlayerRef.current.notice.show = isScheduled + ? '链接已自动刷新' + : '链接已过期并自动刷新'; + } + retryCountRef.current = 0; + startRefreshTimer(preferredHls, preferredVideo); + }; + + /** + * 刷新 xiaoya / openlist 链接(静默刷新,尽量不打断播放) + * - HLS:用 hls.loadSource 换源 + * - 直链(mp4 等):直接改 video.src + * hls / video 可选;定时器触发时会从 artPlayer 重新取 */ const refreshXiaoyaUrl = async ( - hls: any, - video: HTMLVideoElement, - isScheduled = false + preferredHls?: any, + preferredVideo?: HTMLVideoElement, + isScheduled: boolean = false ) => { // 防抖:距离上次刷新不足3秒则不刷新 const now = Date.now(); @@ -2885,7 +2972,12 @@ function PlayPageClient() { if (!isScheduled && retryCountRef.current >= 3) { console.error('[链接刷新] 重试次数已达上限'); setVideoError('链接已过期且刷新失败,请手动刷新页面'); - hls.destroy(); + const { hls } = getPlaybackMedia(preferredHls, preferredVideo); + try { + hls?.destroy?.(); + } catch { + // ignore + } return false; } @@ -2895,6 +2987,11 @@ function PlayPageClient() { return false; } + if (!shouldEnableLinkRefresh()) { + console.log('[链接刷新] 当前路径未开启 refresh14m,跳过'); + return false; + } + console.log(`[链接刷新] 开始刷新 (${isScheduled ? '定时' : '错误触发'})`); setIsRefreshingUrl(true); @@ -2904,11 +3001,22 @@ function PlayPageClient() { lastRefreshTimeRef.current = now; try { - // 保存当前播放进度 - const currentTime = video.currentTime; - const isPaused = video.paused; + const { video: initialVideo } = getPlaybackMedia( + preferredHls, + preferredVideo + ); + if (!initialVideo) { + throw new Error('播放器尚未就绪'); + } - console.log(`[链接刷新] 开始刷新 (${isScheduled ? '定时' : '错误触发'}), 当前时间:`, currentTime); + // 保存当前播放进度 + const currentTime = initialVideo.currentTime; + const isPaused = initialVideo.paused; + + console.log( + `[链接刷新] 开始刷新 (${isScheduled ? '定时' : '错误触发'}), 当前时间:`, + currentTime + ); // 重新获取播放链接(添加时间戳避免缓存) const separator = currentXiaoyaUrlRef.current.includes('?') ? '&' : '?'; @@ -2921,100 +3029,207 @@ function PlayPageClient() { throw new Error('未获取到有效链接'); } + // play 响应可带回 refresh14m,配置热更新后仍一致 + if ( + typeof data.refresh14m === 'boolean' && + currentXiaoyaUrlRef.current.startsWith('/api/openlist/play') + ) { + if (detailRef.current?.source === 'openlist') { + detailRef.current.refresh14m = data.refresh14m; + } + if (!data.refresh14m) { + currentXiaoyaUrlRef.current = ''; + clearRefreshTimer(); + setIsRefreshingUrl(false); + console.log('[链接刷新] play 响应 refresh14m=false,停止续期'); + return false; + } + } + + // 保存清晰度列表(若有) + if (data.qualities && data.qualities.length > 0) { + setVideoQualities(data.qualities); + } + console.log('[链接刷新] 获取到新链接'); - // 先停止HLS加载 - hls.stopLoad(); + // 刷新瞬间再取一次 media(播放器可能已重建) + const { video, hls } = getPlaybackMedia(preferredHls, preferredVideo); + if (!video) { + throw new Error('播放器尚未就绪'); + } - // 使用HLS的loadSource方法直接加载新链接(不改变videoUrl状态) - hls.loadSource(data.url); + // 优先走 HLS 换源;没有 hls 实例时按 progressive 直链处理 + if (hls && typeof hls.loadSource === 'function') { + try { + hls.stopLoad(); + } catch { + // ignore + } + hls.loadSource(data.url); - // 监听加载完成事件,恢复播放进度 - const onManifestParsed = () => { - // 从指定位置开始加载 - hls.startLoad(currentTime); + await new Promise((resolve, reject) => { + const HlsEvents = (hls.constructor as any).Events; + let settled = false; + let finished = false; - // 等待视频可以seek - const onLoadedData = () => { - video.removeEventListener('loadeddata', onLoadedData); + const cleanup = () => { + try { + hls.off(HlsEvents.MANIFEST_PARSED, onManifestParsed); + } catch { + // ignore + } + window.clearTimeout(timeoutId); + }; - // 设置播放位置 - if (currentTime > 0) { - video.currentTime = currentTime; + const complete = () => { + if (finished) return; + finished = true; + finishLinkRefresh(isScheduled, hls, video); + resolve(); + }; - // 等待seek完成 - const onSeeked = () => { - console.log('[链接刷新] 刷新完成,恢复到:', video.currentTime); + const onManifestParsed = () => { + if (settled) return; + settled = true; + cleanup(); - video.removeEventListener('seeked', onSeeked); + try { + hls.startLoad(currentTime); + } catch { + // ignore + } - // 恢复播放状态 - if (!isPaused) { - video.play().catch(err => { - console.warn('[链接刷新] 自动播放失败:', err); - }); + const restorePlayback = () => { + if (currentTime > 0) { + try { + video.currentTime = currentTime; + } catch { + // ignore + } + } + + const afterSeek = () => { + console.log('[链接刷新] HLS 刷新完成,恢复到:', video.currentTime); + if (!isPaused) { + video.play().catch((err) => { + console.warn('[链接刷新] 自动播放失败:', err); + }); + } else { + video.pause(); + } + complete(); + }; + + if (currentTime > 0) { + video.addEventListener('seeked', afterSeek, { once: true }); + // seeked 偶发不触发时兜底 + window.setTimeout(() => { + afterSeek(); + }, 1500); } else { - // 确保暂停状态 - video.pause(); + afterSeek(); } - - setIsRefreshingUrl(false); - - // 显示提示 - if (artPlayerRef.current) { - artPlayerRef.current.notice.show = isScheduled - ? '链接已自动刷新' - : '链接已过期并自动刷新'; - } - - // 刷新成功,重置重试计数 - retryCountRef.current = 0; - - // 重新启动14分钟定时器 - startRefreshTimer(hls, video); }; - video.addEventListener('seeked', onSeeked, { once: true }); - } else { - // 如果是从头开始 + if (video.readyState >= 2) { + restorePlayback(); + } else { + video.addEventListener('loadeddata', restorePlayback, { once: true }); + window.setTimeout(() => { + restorePlayback(); + }, 3000); + } + }; + + hls.once(HlsEvents.MANIFEST_PARSED, onManifestParsed); + + const timeoutId = window.setTimeout(() => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error('加载超时')); + }, 10000); + }); + + return true; + } + + // progressive / 原生播放:直接换 src 并恢复进度 + await new Promise((resolve, reject) => { + let settled = false; + let finished = false; + const cleanup = () => { + video.removeEventListener('loadeddata', onLoaded); + video.removeEventListener('error', onError); + window.clearTimeout(timeoutId); + }; + + const complete = () => { + if (finished) return; + finished = true; + finishLinkRefresh(isScheduled, null, video); + resolve(); + }; + + const onLoaded = () => { + if (settled) return; + settled = true; + cleanup(); + + if (currentTime > 0) { + try { + video.currentTime = currentTime; + } catch { + // ignore + } + } + + const afterSeek = () => { + console.log('[链接刷新] 直链刷新完成,恢复到:', video.currentTime); if (!isPaused) { - video.play().catch(err => { + video.play().catch((err) => { console.warn('[链接刷新] 自动播放失败:', err); }); } else { - // 确保暂停状态 video.pause(); } + complete(); + }; - setIsRefreshingUrl(false); - - if (artPlayerRef.current) { - artPlayerRef.current.notice.show = isScheduled - ? '链接已自动刷新' - : '链接已过期并自动刷新'; - } - - retryCountRef.current = 0; - startRefreshTimer(hls, video); + if (currentTime > 0) { + video.addEventListener('seeked', afterSeek, { once: true }); + window.setTimeout(afterSeek, 1500); + } else { + afterSeek(); } }; - video.addEventListener('loadeddata', onLoadedData, { once: true }); - }; + const onError = () => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error('直链加载失败')); + }; - // 使用 hls.constructor.Events 访问事件常量 - const HlsEvents = (hls.constructor as any).Events; - hls.once(HlsEvents.MANIFEST_PARSED, onManifestParsed); + video.addEventListener('loadeddata', onLoaded, { once: true }); + video.addEventListener('error', onError, { once: true }); + const timeoutId = window.setTimeout(() => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error('加载超时')); + }, 10000); - // 添加超时保护(10秒内未加载完成则认为失败) - setTimeout(() => { - hls.off(HlsEvents.MANIFEST_PARSED, onManifestParsed); - if (isRefreshingUrl) { - console.error('[链接刷新] 加载超时'); - setIsRefreshingUrl(false); - throw new Error('加载超时'); + try { + video.src = data.url; + video.load(); + } catch (err) { + settled = true; + cleanup(); + reject(err instanceof Error ? err : new Error(String(err))); } - }, 10000); + }); return true; } catch (error) { @@ -3026,8 +3241,8 @@ function PlayPageClient() { console.warn('[链接刷新] 定时刷新失败,继续使用旧链接'); // 5分钟后再试一次 setTimeout(() => { - if (hls && video && currentXiaoyaUrlRef.current) { - refreshXiaoyaUrl(hls, video, true); + if (currentXiaoyaUrlRef.current && shouldEnableLinkRefresh()) { + refreshXiaoyaUrl(undefined, undefined, true); } }, 5 * 60 * 1000); return false; @@ -3037,13 +3252,18 @@ function PlayPageClient() { if (retryCountRef.current < 3) { console.log(`[链接刷新] 2秒后重试 (${retryCountRef.current}/3)`); setTimeout(() => { - if (hls && video && currentXiaoyaUrlRef.current) { - refreshXiaoyaUrl(hls, video, false); + if (currentXiaoyaUrlRef.current && shouldEnableLinkRefresh()) { + refreshXiaoyaUrl(undefined, undefined, false); } }, 2000); } else { setVideoError('链接刷新失败,请手动刷新页面'); - hls.destroy(); + const { hls } = getPlaybackMedia(preferredHls, preferredVideo); + try { + hls?.destroy?.(); + } catch { + // ignore + } } return false; @@ -3053,8 +3273,9 @@ function PlayPageClient() { /** * 启动14分钟定时刷新器 * xiaoya:始终启用;openlist:仅当 detail.refresh14m 为 true + * 不依赖 hls 是否已就绪;到期时再取当前播放器实例 */ - const startRefreshTimer = (hls: any, video: HTMLVideoElement) => { + const startRefreshTimer = (preferredHls?: any, preferredVideo?: HTMLVideoElement) => { // 清除旧定时器 if (refreshTimerRef.current) { clearTimeout(refreshTimerRef.current); @@ -3066,23 +3287,40 @@ function PlayPageClient() { return; } - // openlist 仅在 PathMeta.refresh14m 开启时续期;xiaoya 保持原逻辑 + if (!shouldEnableLinkRefresh()) { + const isOpenlistPlayUrl = currentXiaoyaUrlRef.current.startsWith( + '/api/openlist/play' + ); + if (isOpenlistPlayUrl) { + console.log('[定时刷新] OpenList 路径未开启 refresh14m,跳过'); + } + linkRefreshEligibleRef.current = false; + return; + } + + // xiaoya:仅 m3u8 可启(由 shouldStartLinkRefreshTimer 写入 eligible) + // openlist:eligible 在 refresh14m 判定时已置 true const isOpenlistPlayUrl = currentXiaoyaUrlRef.current.startsWith( '/api/openlist/play' ); - if (isOpenlistPlayUrl && !detailRef.current?.refresh14m) { - console.log('[定时刷新] OpenList 路径未开启 refresh14m,跳过'); + if (!isOpenlistPlayUrl && !linkRefreshEligibleRef.current) { + console.log('[定时刷新] xiaoya 非 m3u8,跳过定时器(保持原逻辑)'); return; } + if (isOpenlistPlayUrl) { + linkRefreshEligibleRef.current = true; + } console.log('[定时刷新] 启动14分钟定时器'); // 14分钟 = 840000毫秒 refreshTimerRef.current = setTimeout(() => { console.log('[定时刷新] 14分钟到期,开始刷新'); - if (hls && video && currentXiaoyaUrlRef.current) { - refreshXiaoyaUrl(hls, video, true); + if (!currentXiaoyaUrlRef.current || !shouldEnableLinkRefresh()) { + return; } + // 优先用调用时传入的实例,否则从当前播放器取 + refreshXiaoyaUrl(preferredHls, preferredVideo, true); }, 14 * 60 * 1000); }; @@ -3106,6 +3344,7 @@ function PlayPageClient() { retryCountRef.current = 0; lastRefreshTimeRef.current = 0; currentXiaoyaUrlRef.current = ''; // 清空旧的原始URL + linkRefreshEligibleRef.current = false; clearRefreshTimer(); // 清除旧的定时器 isInitialLoadRef.current = true; // 重置为首次加载 @@ -3177,16 +3416,14 @@ function PlayPageClient() { if (isSpecialLazyPlayUrl) { try { - // 保存原始URL(用于后续刷新) - // xiaoya:始终保存;openlist:仅 refresh14m 时保存(否则不启 14 分钟续期) - if (newUrl.startsWith('/api/xiaoya/play')) { - currentXiaoyaUrlRef.current = newUrl; - } else if (newUrl.startsWith('/api/openlist/play')) { - if (detailData?.refresh14m) { - currentXiaoyaUrlRef.current = newUrl; - } else { - currentXiaoyaUrlRef.current = ''; - } + // 保存原始 lazy URL(用于后续刷新) + // xiaoya:始终可刷新;openlist:先保存,再以 play 响应的 refresh14m 最终裁定 + const originalLazyUrl = newUrl; + if ( + originalLazyUrl.startsWith('/api/xiaoya/play') || + originalLazyUrl.startsWith('/api/openlist/play') + ) { + currentXiaoyaUrlRef.current = originalLazyUrl; } // 添加 format=json 参数 @@ -3201,25 +3438,19 @@ function PlayPageClient() { if (data.url) { newUrl = data.url; // play 响应可带回 refresh14m,覆盖 detail(配置热更新后仍一致) - if ( - typeof data.refresh14m === 'boolean' && - detailData?.source === 'openlist' - ) { - detailData.refresh14m = data.refresh14m; + if (originalLazyUrl.startsWith('/api/openlist/play')) { + const refresh14m = + typeof data.refresh14m === 'boolean' + ? data.refresh14m + : Boolean(detailData?.refresh14m); + if (detailData) { + detailData.refresh14m = refresh14m; + } if (detailRef.current?.source === 'openlist') { - detailRef.current.refresh14m = data.refresh14m; - } - if (data.refresh14m && currentXiaoyaUrlRef.current === '') { - // 若此前因 detail 未带 flag 未保存,用原始 lazy url 兜底 - const originalLazy = - detailData?.episodes?.[episodeIndex] || ''; - if (originalLazy.startsWith('/api/openlist/play')) { - currentXiaoyaUrlRef.current = originalLazy; - } - } - if (!data.refresh14m) { - currentXiaoyaUrlRef.current = ''; + detailRef.current.refresh14m = refresh14m; } + // 未开启续期时清空,避免误启定时器 + currentXiaoyaUrlRef.current = refresh14m ? originalLazyUrl : ''; } // 保存清晰度列表 if (data.qualities && data.qualities.length > 0) { @@ -3227,6 +3458,19 @@ function PlayPageClient() { } else { setVideoQualities([]); } + + // 拿到真实播放地址后立刻启动 14 分钟续期(避免仅依赖 MANIFEST_PARSED) + // xiaoya:仅 m3u8;openlist:refresh14m 即可(签名链常无扩展名) + if ( + currentXiaoyaUrlRef.current && + shouldStartLinkRefreshTimer(newUrl) + ) { + isInitialLoadRef.current = false; + startRefreshTimer(); + } + } else if (originalLazyUrl.startsWith('/api/openlist/play')) { + // 未拿到 url 时不保留 lazy url,避免无效续期 + currentXiaoyaUrlRef.current = ''; } } catch (error) { if (requestSeq !== videoUrlRequestSeqRef.current) { @@ -6985,9 +7229,14 @@ function PlayPageClient() { } } - // 只在首次加载时启动定时器(后续刷新会在refreshXiaoyaUrl中启动) - if (isInitialLoadRef.current && currentXiaoyaUrlRef.current && url.includes('.m3u8')) { - isInitialLoadRef.current = false; // 标记已完成首次加载 + // 兜底:若 updateVideoUrl 时尚未启定时器,在 manifest 解析后再启 + // xiaoya:仅 m3u8;openlist:refresh14m 即可(此回调本身已在 HLS 路径) + if ( + isInitialLoadRef.current && + currentXiaoyaUrlRef.current && + shouldStartLinkRefreshTimer(url) + ) { + isInitialLoadRef.current = false; startRefreshTimer(hls, video); } });