From d462ec559e7ce739e1a518148e6858768121c048 Mon Sep 17 00:00:00 2001 From: liuxionghui Date: Tue, 4 Aug 2026 14:32:11 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Safari=20HLS=20fall?= =?UTF-8?q?back=E3=80=81=E6=8F=92=E4=BB=B6=E9=94=80=E6=AF=81=E8=B7=B3?= =?UTF-8?q?=E8=BF=87=E3=80=81React=20ref=20null=E3=80=81=E6=8D=A2=E9=9B=86?= =?UTF-8?q?=E6=97=A7=20ID=E3=80=81=E8=8F=9C=E5=8D=95=E6=AE=8B=E7=95=99?= =?UTF-8?q?=E3=80=81Vue=20=E7=BC=BA=E5=A4=B1=20props?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core: Safari 原生 HLS fallback 不再被 import('hls.js') 错误阻断 - core: 插件销毁 forEach+splice 改为复制数组遍历,修复跳过一半 - core: load() 清空 engine-managed 画质/音轨菜单,修复换源后残留 - core: 新增 setId(id) 切换视频 ID,保存旧进度后重新绑定 - react: useImperativeHandle 去掉 deps=[],修复 ref 始终 null - react: watch id prop 变化调用 setId - vue: 补全 loop/shareUrl/onSavePrefs/onSaveProgress props - vue: watch id prop 变化调用 setId --- .changeset/fix-review-issues.md | 16 ++++++++++ packages/core/src/core/media.ts | 29 ++++++++++++++--- packages/core/src/player.ts | 50 ++++++++++++++++++++++++++---- packages/react/src/SweetPlayer.tsx | 8 ++++- packages/vue/src/SweetPlayer.ts | 16 ++++++++++ 5 files changed, 107 insertions(+), 12 deletions(-) create mode 100644 .changeset/fix-review-issues.md diff --git a/.changeset/fix-review-issues.md b/.changeset/fix-review-issues.md new file mode 100644 index 0000000..4e3b260 --- /dev/null +++ b/.changeset/fix-review-issues.md @@ -0,0 +1,16 @@ +--- +'@sweet-player/core': patch +'@sweet-player/react': patch +'@sweet-player/vue': patch +--- + +修复外部 review 发现的 6 个问题: + +- **core: Safari 原生 HLS fallback**:`import('hls.js')` 失败时不再阻断原生 HLS 检测,先检测 `canPlayType` 再尝试 import +- **core: 插件销毁跳过一半**:`destroy()` 中 `forEach` + `splice` 导致每隔一个插件被跳过,改为复制数组遍历 +- **core: 换源后菜单残留**:`load()` 现在清空 engine-managed 画质/音轨菜单,避免旧数据点击无效 +- **core: 新增 `setId(id)`**:切换视频 ID 时保存旧进度、解绑旧事件、重新绑定,修复换集写入旧 ID +- **react: ref 始终 null**:`useImperativeHandle` 的 deps 从 `[]` 改为无 deps(每次 render 刷新),修复 ref 不可用 +- **react: watch `id` prop**:`id` 变化时调用 `player.setId(id)` +- **vue: 补全缺失 props**:新增 `loop`、`shareUrl`、`onSavePrefs`、`onSaveProgress` +- **vue: watch `id` prop**:`id` 变化时调用 `player.setId(id)` diff --git a/packages/core/src/core/media.ts b/packages/core/src/core/media.ts index 7ef1c41..ff85fd1 100644 --- a/packages/core/src/core/media.ts +++ b/packages/core/src/core/media.ts @@ -72,6 +72,30 @@ export class MediaController { return; } void (async () => { + // 先检测原生 HLS 支持(Safari),这样即使 hls.js 未安装也能播放 + if (this.video.canPlayType('application/vnd.apple.mpegurl')) { + try { + const [{ createHlsEngine }, { default: Hls }] = await Promise.all([ + import('./engines/hlsEngine'), + import('hls.js'), + ]); + if (seq !== this.loadSeq) return; + if (Hls.isSupported()) { + this.engine = createHlsEngine(this.video, src, engineCallbacks, this.hlsConfig); + return; + } + } catch { + // hls.js 未安装或加载失败——但浏览器原生支持 HLS,降级使用 + if (seq !== this.loadSeq) return; + log('hls事件', 'hls.js 不可用,降级原生 HLS'); + } + // 走原生 HLS + if (seq !== this.loadSeq) return; + this.nativeHlsOnly = true; + this.video.src = src; + return; + } + // 浏览器不支持原生 HLS,必须依赖 hls.js try { const [{ createHlsEngine }, { default: Hls }] = await Promise.all([ import('./engines/hlsEngine'), @@ -82,11 +106,6 @@ export class MediaController { this.engine = createHlsEngine(this.video, src, engineCallbacks, this.hlsConfig); return; } - if (this.video.canPlayType('application/vnd.apple.mpegurl')) { - this.nativeHlsOnly = true; - this.video.src = src; - return; - } this.emitter.emit('error', { type: 'hls-unsupported' }); } catch (err) { if (seq !== this.loadSeq) return; diff --git a/packages/core/src/player.ts b/packages/core/src/player.ts index a5ca09e..49b3374 100644 --- a/packages/core/src/player.ts +++ b/packages/core/src/player.ts @@ -85,6 +85,10 @@ export class SweetPlayer { /** 偏好 / 进度的节流写入队列;未传对应回调时为 null(走 localStorage 或完全不存) */ private prefsQueue: ReturnType> | null = null; private progressQueue: ReturnType> | null = null; + /** 当前进度记忆的视频 ID;setId 时切换 */ + private progressId: string | null = null; + /** 进度记忆的清理函数;setId / destroy 时调用 */ + private progressCleanup: (() => void) | null = null; private disposers: Array<() => void> = []; private pluginCleanups: Array<() => void> = []; private destroyed = false; @@ -277,7 +281,10 @@ export class SweetPlayer { this.controls.updateRate(this.video.playbackRate); if (persist || remotePrefs) this.bindPersistence(); - if (options.id) this.bindProgressMemory(options.id); + if (options.id) { + this.progressId = options.id; + this.bindProgressMemory(options.id); + } options.plugins?.forEach((p) => this.use(p)); } @@ -507,9 +514,34 @@ export class SweetPlayer { load(src: string): void { this.state.hideEnded(); this.state.hideError(); + // 换源后旧引擎的画质/音轨菜单不再适用,清空避免残留 + if (this.engineManagedQuality) { + this.engineManagedQuality = false; + this.controls.qualityMenu.setItems([]); + } + if (this.engineManagedAudio) { + this.engineManagedAudio = false; + this.controls.audioMenu.setItems([]); + } this.media.load(src); } + /** + * 切换视频 ID(断点续播的 key)。 + * 先保存旧视频的进度,再解绑旧的事件/定时器,最后用新 ID 重新绑定。 + */ + setId(id: string): void { + if (this.progressId === id) return; + this.saveProgressNow(); + this.progressQueue?.flush(); + this.progressCleanup?.(); + this.progressCleanup = null; + this.progressQueue = null; + this.progressId = id; + this.options.id = id; + this.bindProgressMemory(id); + } + setTitle(title: string): void { const el = this.controls.topEl.querySelector('.sp-title'); if (el) el.textContent = title; @@ -556,7 +588,8 @@ export class SweetPlayer { this.saveProgressNow(); this.progressQueue?.flush(); // 队列是异步的,销毁前立即写掉 this.emitter.emit('destroy', undefined); - this.pluginCleanups.forEach((d) => d()); + // 复制后遍历:dispose 内部会 splice 原数组,直接 forEach 会跳过一半 + [...this.pluginCleanups].forEach((d) => d()); // 销毁前保证网页全屏被解除,避免残留 body class 与 escape listener if (this.webFullscreen) this.exitWebFullscreen(); this.keyboard.destroy(); @@ -569,10 +602,10 @@ export class SweetPlayer { this.tapFlash.destroy(); // 清理可能悬挂的 restore listener(load 失败 / metadata 未到时) this.restoreAbort?.abort(); + this.progressCleanup?.(); this.disposers.forEach((d) => d()); if (this.hideTimer) clearTimeout(this.hideTimer); if (this.clickTimer) clearTimeout(this.clickTimer); - if (this.progressTimer) clearInterval(this.progressTimer); this.media.destroy(); this.emitter.removeAll(); this.container.remove(); @@ -671,14 +704,19 @@ export class SweetPlayer { }; window.addEventListener('pagehide', onPageHide); - this.disposers.push(() => { + this.progressCleanup = () => { this.video.removeEventListener('seeked', onSeeked); window.removeEventListener('pagehide', onPageHide); - }); + if (this.progressTimer) { + clearInterval(this.progressTimer); + this.progressTimer = null; + } + this.progressQueue = null; + }; } private saveProgressNow(): void { - if (!this.options.id || !this.video.duration) return; + if (!this.progressId || !this.video.duration) return; const ended = this.video.currentTime >= this.video.duration - PROGRESS_END_GUARD; if (!ended && this.video.currentTime <= 3) return; // 刚开头,没必要记 this.progressQueue?.push(ended ? null : this.video.currentTime); diff --git a/packages/react/src/SweetPlayer.tsx b/packages/react/src/SweetPlayer.tsx index e344383..4769220 100644 --- a/packages/react/src/SweetPlayer.tsx +++ b/packages/react/src/SweetPlayer.tsx @@ -18,7 +18,9 @@ export const SweetPlayer = forwardRef(funct const optionsRef = useRef({ ...options, src, onReady }); optionsRef.current = { ...options, src, onReady }; - useImperativeHandle(ref, () => playerRef.current as CorePlayer, []); + // useImperativeHandle 的 factory 在 commit 阶段执行,早于创建 player 的 useEffect。 + // 因此直接返回 ref.current——它会在 effect 中被赋值,父组件通过 ref.current 拿到实例。 + useImperativeHandle(ref, () => playerRef.current as CorePlayer); useEffect(() => { if (!containerRef.current) return; @@ -43,5 +45,9 @@ export const SweetPlayer = forwardRef(funct playerRef.current?.setTitle(options.title ?? ''); }, [options.title]); + useEffect(() => { + if (options.id) playerRef.current?.setId(options.id); + }, [options.id]); + return
; }); diff --git a/packages/vue/src/SweetPlayer.ts b/packages/vue/src/SweetPlayer.ts index 7b735a3..a61e086 100644 --- a/packages/vue/src/SweetPlayer.ts +++ b/packages/vue/src/SweetPlayer.ts @@ -15,6 +15,7 @@ import { type ControlName, type HeatmapPoint, type LongSeekOptions, + type PlayerPrefs, type QualityLevel, type SweetPlayerPlugin, } from '@sweet-player/core'; @@ -37,6 +38,8 @@ export const SweetPlayer = defineComponent({ heatmap: Array as PropType, poster: String, thumbnails: String, + shareUrl: String, + loop: Boolean, autoQuality: { type: Boolean, default: true }, persist: { type: Boolean, default: true }, autoNext: [Boolean, Number] as PropType, @@ -46,6 +49,8 @@ export const SweetPlayer = defineComponent({ plugins: Array as PropType, hlsConfig: Object as PropType>, dashConfig: Object as PropType>, + onSavePrefs: Function as PropType<(prefs: PlayerPrefs) => void | Promise>, + onSaveProgress: Function as PropType<(id: string, seconds: number | null) => void | Promise>, }, emits: [ 'ready', @@ -84,6 +89,8 @@ export const SweetPlayer = defineComponent({ heatmap: props.heatmap, poster: props.poster, thumbnails: props.thumbnails, + shareUrl: props.shareUrl, + loop: props.loop, autoQuality: props.autoQuality, persist: props.persist, autoNext: props.autoNext, @@ -93,6 +100,8 @@ export const SweetPlayer = defineComponent({ plugins: props.plugins, hlsConfig: props.hlsConfig, dashConfig: props.dashConfig, + onSavePrefs: props.onSavePrefs, + onSaveProgress: props.onSaveProgress, onPrev: () => emit('prev'), onNext: () => emit('next'), onQualityChange: (q) => emit('quality-change', q), @@ -122,6 +131,13 @@ export const SweetPlayer = defineComponent({ (title) => player.value?.setTitle(title ?? ''), ); + watch( + () => props.id, + (id) => { + if (id) player.value?.setId(id); + }, + ); + watch( () => props.qualities, (qualities) => {