Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/fix-round2-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@sweet-player/core': minor
'@sweet-player/react': minor
'@sweet-player/vue': minor
---

修复第二轮 review 发现的问题,并让 HLS/DASH 引擎开箱即用:

**React ref 首次挂载即可用**
- 去掉 `useImperativeHandle`(其 factory 执行早于 player 创建,且 effect 赋值不触发 render)
- 在浏览器 layout effect 中创建 player 并维护 forwarded ref,父组件的 layout effect 可直接读取实例

**setId 换集修复**
- `progressCleanup` 现在移除 `loadedmetadata` restore listener,避免旧 ID 进度恢复到新视频
- React/Vue 的 `id` 更新先于 `src` 执行:先保存旧进度再换源,防止进度丢失
- `setId` 签名扩展为 `setId(id: string | null)`,传 `null` 解除进度记忆

**运行时回调不再用旧闭包**
- core 新增 `setCallbacks()`,导航按钮和持久化写入目标可在运行时切换
- React:稳定代理函数转发到最新 props,新增、替换或移除 `onNext`/`onPrev`/`onSavePrefs` 等回调都会生效
- Vue:动态同步 `onSavePrefs`/`onSaveProgress`,未监听 `prev`/`next` 时对应按钮保持禁用

**HLS/DASH 引擎开箱即用**
- `hls.js` / `dashjs` 从 optional peer 改为常规 peer(npm 7+ 安装 core 时自动安装),Chrome/Firefox HLS 与 DASH 播放不再因缺依赖失败
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ npm install @sweet-player/core
| `@sweet-player/react` | React component wrapper |
| `@sweet-player/vue` | Vue component wrapper |

`hls.js` and `dashjs` are peer/optional dependencies loaded on demand: `.m3u8` sources pull in `hls.js`, `.mpd` sources pull in `dashjs`; any other source plays through the native `<video>`. Only the engine you actually use is downloaded.
`hls.js` and `dashjs` are peer dependencies that npm installs automatically with `@sweet-player/core` — no extra setup needed. `.m3u8` sources use `hls.js`, `.mpd` sources use `dashjs`, and any other source plays through the native `<video>`.

## Quick Start

Expand Down Expand Up @@ -145,7 +145,9 @@ const player = new SweetPlayer({
| `screenshot()` | Copy the current frame to the clipboard, or download it. |
| `copyShareUrl()` | Copy the video URL (`shareUrl`, or `location.href`) to the clipboard. |
| `load(src)` | Load a new source without recreating the player. |
| `setId(id)` | Change the progress key; pass `null` to stop remembering position. |
| `setTitle(title)` | Update the top-left title text. |
| `setCallbacks(callbacks)` | Replace navigation, track-change, and persistence callbacks at runtime. |
| `use(plugin)` | Install a plugin at runtime; returns an uninstall function. |
| `addSettingsRow(section)` | Register a settings-panel row; returns a remove function. |
| `addContextMenuItem(item, index?)` | Register a right-click menu item; returns a remove function. |
Expand Down
4 changes: 3 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ npm install @sweet-player/core
| `@sweet-player/react` | React 组件封装 |
| `@sweet-player/vue` | Vue 组件封装 |

`hls.js` 和 `dashjs` 按需加载:`.m3u8` 源自动引入 `hls.js`,`.mpd` 源自动引入 `dashjs`,其余源直接走原生 `<video>`。只有实际用到的引擎会下载。
`hls.js` 和 `dashjs` 是随 `@sweet-player/core` 自动安装的 peer 依赖,无需额外配置:`.m3u8` 源使用 `hls.js`,`.mpd` 源使用 `dashjs`,其余源直接走原生 `<video>`。

## 快速开始

Expand Down Expand Up @@ -145,7 +145,9 @@ const player = new SweetPlayer({
| `screenshot()` | 截取当前画面,优先复制剪贴板,否则下载 |
| `copyShareUrl()` | 复制视频地址(`shareUrl`,缺省为 `location.href`)到剪贴板 |
| `load(src)` | 加载新的播放源,无需重建播放器 |
| `setId(id)` | 更换断点续播 key;传 `null` 停止记忆进度 |
| `setTitle(title)` | 更新左上角标题 |
| `setCallbacks(callbacks)` | 运行时替换导航、轨道切换与持久化回调 |
| `use(plugin)` | 运行时安装插件,返回卸载函数 |
| `addSettingsRow(section)` | 注册设置面板行,返回移除函数 |
| `addContextMenuItem(item, index?)` | 注册右键菜单项,返回移除函数 |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"dev:vanilla": "pnpm --filter playground-vanilla dev",
"dev:react": "pnpm --filter playground-react dev",
"dev:vue": "pnpm --filter playground-vue dev",
"test": "pnpm --filter @sweet-player/core test",
"test": "pnpm -r --filter \"./packages/*\" test",
"typecheck": "pnpm -r --filter \"./packages/*\" typecheck",
"changeset": "changeset",
"release": "changeset && git add .changeset && git commit -m \"chore: add changeset\" && git push"
Expand Down
8 changes: 0 additions & 8 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,6 @@
"dashjs": "^5",
"hls.js": "^1.5.18"
},
"peerDependenciesMeta": {
"dashjs": {
"optional": true
},
"hls.js": {
"optional": true
}
},
"devDependencies": {
"dashjs": "^5",
"hls.js": "^1.5.18",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type { SettingsSection, SettingsItem } from './ui/components/settingsPane
export type { ContextMenuItem } from './ui/components/contextMenu';
export type {
SweetPlayerOptions,
SweetPlayerCallbacks,
SweetPlayerPlugin,
SweetPlayerLike,
QualityLevel,
Expand Down
62 changes: 43 additions & 19 deletions packages/core/src/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type {
PlayerPrefs,
QualityLevel,
RestoreState,
SweetPlayerCallbacks,
SweetPlayerOptions,
SweetPlayerPlugin,
} from './types';
Expand Down Expand Up @@ -280,7 +281,8 @@ export class SweetPlayer {
this.controls.volume.update(Math.round(this.video.volume * 100), this.video.muted);
this.controls.updateRate(this.video.playbackRate);

if (persist || remotePrefs) this.bindPersistence();
// 始终绑定轻量事件:persist 或远端回调可在运行时切换,实际写入时再决定目标。
this.bindPersistence();
if (options.id) {
this.progressId = options.id;
this.bindProgressMemory(options.id);
Expand Down Expand Up @@ -529,17 +531,31 @@ export class SweetPlayer {
/**
* 切换视频 ID(断点续播的 key)。
* 先保存旧视频的进度,再解绑旧的事件/定时器,最后用新 ID 重新绑定。
* 传 `null` 表示解除进度记忆(停止保存,也不恢复)。
*/
setId(id: string): void {
setId(id: string | null): void {
if (this.progressId === id) return;
// 保存旧进度:必须在旧媒体还挂着时读取 currentTime,
// 换源(load)之后再调会读到新媒体/重置后的值,进度丢失
this.saveProgressNow();
this.progressQueue?.flush();
this.progressCleanup?.();
this.progressCleanup = null;
this.progressQueue = null;
this.progressId = id;
this.options.id = id;
this.bindProgressMemory(id);
this.options.id = id ?? undefined;
if (id) this.bindProgressMemory(id);
}

/** 更新包装层可在运行时变化的回调,不重建播放器或重复绑定媒体事件。 */
setCallbacks(callbacks: SweetPlayerCallbacks): void {
this.options.onPrev = callbacks.onPrev;
this.options.onNext = callbacks.onNext;
this.options.onQualityChange = callbacks.onQualityChange;
this.options.onAudioTrackChange = callbacks.onAudioTrackChange;
this.options.onSavePrefs = callbacks.onSavePrefs;
this.options.onSaveProgress = callbacks.onSaveProgress;
this.controls.updateNavigation(callbacks.onPrev, callbacks.onNext);
}

setTitle(title: string): void {
Expand Down Expand Up @@ -654,8 +670,12 @@ export class SweetPlayer {
// ---------- 内部:持久化 ----------

private bindPersistence(): void {
// 有 onSavePrefs 走业务方回调,否则回落到 localStorage。两条路径共用同一个节流队列。
const write = this.options.onSavePrefs ?? ((p: PlayerPrefs) => savePrefs(p));
// 每次发送时读取当前回调,支持包装层在运行时切换远端/本地持久化。
const write = (prefs: PlayerPrefs): void | Promise<void> => {
const onSavePrefs = this.options.onSavePrefs;
if (onSavePrefs) return onSavePrefs(prefs);
if (this.options.persist !== false) savePrefs(prefs);
};
this.prefsQueue = createSaveQueue<PlayerPrefs>(write, PREFS_SAVE_DEBOUNCE, '偏好');

const onVolume = () =>
Expand All @@ -671,23 +691,24 @@ export class SweetPlayer {
}

private bindProgressMemory(id: string): void {
const onSaveProgress = this.options.onSaveProgress;
// 两条路径共用队列:seek 连点时合并写入,pause / pagehide / destroy 时 flush 立即落盘
const write = onSaveProgress
? (seconds: number | null) => onSaveProgress(id, seconds)
: (seconds: number | null) => (seconds === null ? clearProgress(id) : saveProgress(id, seconds));
const write = (seconds: number | null): void | Promise<void> => {
const onSaveProgress = this.options.onSaveProgress;
if (onSaveProgress) return onSaveProgress(id, seconds);
if (seconds === null) clearProgress(id);
else saveProgress(id, seconds);
};
this.progressQueue = createSaveQueue<number | null>(write, PROGRESS_SAVE_DEBOUNCE, '进度');

// 业务方自管进度时只负责写,读由业务方拿到数据后调 restore()
if (!onSaveProgress) {
const restore = () => {
const saved = loadProgress(id);
if (saved !== null && saved > 3 && saved < this.video.duration - PROGRESS_END_GUARD) {
this.video.currentTime = saved;
}
};
this.video.addEventListener('loadedmetadata', restore, { once: true });
}
const restore = () => {
if (this.options.onSaveProgress) return;
const saved = loadProgress(id);
if (saved !== null && saved > 3 && saved < this.video.duration - PROGRESS_END_GUARD) {
this.video.currentTime = saved;
}
};
this.video.addEventListener('loadedmetadata', restore, { once: true });

this.progressTimer = setInterval(() => {
if (!this.video.paused) this.saveProgressNow();
Expand All @@ -705,6 +726,9 @@ export class SweetPlayer {
window.addEventListener('pagehide', onPageHide);

this.progressCleanup = () => {
// restore 是 { once: true },未触发时 removeEventListener 同样有效;
// 必须移除,否则换集后旧 id 的 restore 会在新源的 loadedmetadata 上误 seek
this.video.removeEventListener('loadedmetadata', restore);
this.video.removeEventListener('seeked', onSeeked);
window.removeEventListener('pagehide', onPageHide);
if (this.progressTimer) {
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,11 @@ export interface SweetPlayerOptions {
onAudioTrackChange?: (track: AudioTrackInfo) => void;
}

export type SweetPlayerCallbacks = Pick<
SweetPlayerOptions,
'onPrev' | 'onNext' | 'onQualityChange' | 'onAudioTrackChange' | 'onSavePrefs' | 'onSaveProgress'
>;

export interface PlayerEventMap {
ready: void;
play: void;
Expand Down
11 changes: 9 additions & 2 deletions packages/core/src/ui/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export interface Controls {
updatePip(pip: boolean): void;
updateLoop(loop: boolean): void;
updateRatio(ratio: AspectRatio): void;
updateNavigation(onPrev?: () => void, onNext?: () => void): void;
destroy(): void;
}

Expand Down Expand Up @@ -94,11 +95,11 @@ export function createControls(ctx: ControlsContext): Controls {
};

// 左侧:上一个 | 快退 | 播放 | 快进 | 下一个 | 时间
button(icons.prev, i18n.t('prev'), () => actions.onPrev?.(), !actions.onPrev, show('prev'));
const prevBtn = button(icons.prev, i18n.t('prev'), () => actions.onPrev?.(), !actions.onPrev, show('prev'));
button(icons.seekBack, i18n.t('seekBack', { n: ctx.seekStep }), () => actions.seekBy(-ctx.seekStep), false, show('seekBack'));
const playBtn = button(icons.play, i18n.t('playPause'), actions.togglePlay, false, show('play'));
button(icons.seekForward, i18n.t('seekForward', { n: ctx.seekStep }), () => actions.seekBy(ctx.seekStep), false, show('seekForward'));
button(icons.next, i18n.t('next'), () => actions.onNext?.(), !actions.onNext, show('next'));
const nextBtn = button(icons.next, i18n.t('next'), () => actions.onNext?.(), !actions.onNext, show('next'));

const timeEl = createEl('span', {
className: 'sp-time',
Expand Down Expand Up @@ -292,6 +293,12 @@ export function createControls(ctx: ControlsContext): Controls {
const label = ratio === 'original' ? i18n.t('ratioOriginal') : ratio;
settingsPanel.updateSection('ratio', { activeValue: ratio, currentValue: label });
},
updateNavigation(onPrev, onNext) {
actions.onPrev = onPrev;
actions.onNext = onNext;
prevBtn.classList.toggle('sp-disabled', !onPrev);
nextBtn.classList.toggle('sp-disabled', !onNext);
},
destroy() {
settingsPanel.destroy();
progress.destroy();
Expand Down
7 changes: 6 additions & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"sideEffects": false,
"scripts": {
"build": "tsup",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"license": "MIT",
Expand All @@ -43,8 +44,12 @@
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"jsdom": "^29.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tsup": "^8.3.5",
"typescript": "^5.7.2"
"typescript": "^5.7.2",
"vitest": "^4.1.10"
}
}
Loading
Loading