English | 中文
A custom video player supporting HLS (hls.js) and MPEG-DASH (dashjs). Zero framework dependency at its core, with React / Vue / vanilla JS support. Written in TypeScript.
Live Demo: player.sweetui.com
Sweet Player is built for on-demand video. Two things are deliberately out of scope:
| Status | If you need it | |
|---|---|---|
| DRM / encrypted playback | Not supported | Shaka Player — Widevine / PlayReady / FairPlay out of the box |
| Live streaming | Not supported | Video.js with a live plugin, or Shaka Player |
These aren't gaps waiting to be filled — supporting them properly means EME plus per-platform licence handling for DRM, and a DVR window, low-latency tuning and completely different progress-bar semantics for live. Both would roughly double the surface area of a player whose point is to stay small.
Nothing stops you from pointing the player at a live or encrypted source — hlsConfig / dashConfig pass straight through to the underlying engines, which do have the capability. It will play. But the layer above is not adapted for it: the progress bar sits at 0% and the duration reads 0:00, because the UI assumes a known, fixed length.
That path is unsupported. Issues and feature requests about live or DRM playback won't be worked on — this is a scope decision, not a backlog item.
npm install @sweet-player/core| Package | Description |
|---|---|
@sweet-player/core |
Core player (full UI included), for vanilla JS or as the base for framework wrappers |
@sweet-player/react |
React component wrapper |
@sweet-player/vue |
Vue component wrapper |
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>.
import { SweetPlayer } from '@sweet-player/core';
const player = new SweetPlayer({
container: '#player',
src: 'https://example.com/video.m3u8',
});<script src="https://unpkg.com/@sweet-player/core/dist/sweet-player.global.js"></script>
<script>
const player = new SweetPlayer({ container: '#player', src: '...' });
</script>npm install @sweet-player/reactimport { SweetPlayer, type SweetPlayerCore } from '@sweet-player/react';
const ref = useRef<SweetPlayerCore | null>(null);
<SweetPlayer ref={ref} src="..." title="..." id="ep-01" onNext={() => {}} />;
ref.current?.seekBy(30);npm install @sweet-player/vue<SweetPlayer :src="src" title="..." id="ep-01" @ready="p => (player = p)" @next="..." />const player = new SweetPlayer({
container: '#player', // Element or CSS selector
src: 'https://example.com/video.m3u8',
title: 'Video Title',
id: 'ep-01', // Enables resume playback from last position
volume: 80, // 0-100 (localStorage preference takes priority)
seekStep: 10, // Seek step in seconds
longSeek: { steps: [10, 30, 60], stepUpInterval: 2000 },
playbackRates: [0.5, 1, 1.5, 2],
autoQuality: true, // Default true: auto-populate quality menu from HLS/DASH levels
persist: true, // Default true: remember volume/mute/rate in localStorage
autoNext: 5, // Auto-play next after 5s countdown on ended (requires onNext)
locale: 'en', // Built-in: 'zh-CN' / 'en'; extend with registerLocale
heatmap: [{ time: 5, value: 88 }], // Most-replayed curve above the progress bar (values auto-normalized)
poster: '/poster.webp', // Cover image shown before playback starts
thumbnails: '/thumbs.vtt', // WebVTT thumbnail track for progress-bar hover preview
shareUrl: 'https://…', // URL copied by the "Copy video URL" menu item (default: location.href)
hlsConfig: {}, // Passed through to `new Hls(config)` for .m3u8 sources
dashConfig: {}, // Passed through to dashjs `updateSettings` for .mpd sources
hiddenControls: ['ratio'], // Hide specific UI controls, all shown by default
plugins: [], // Plugin list
onPrev: () => {},
onNext: () => {},
onQualityChange: (q) => {},
onAudioTrackChange: (t) => {},
onSavePrefs: (prefs) => {}, // Save to your backend instead of localStorage
onSaveProgress: (id, seconds) => {}, // seconds === null means finished — clear the record
});| Method | Description |
|---|---|
play() |
Start playback. Returns a Promise. |
pause() |
Pause playback. |
toggle() |
Toggle play / pause. |
seek(time) |
Jump to an absolute time in seconds. |
seekBy(delta) |
Seek by a relative offset in seconds (positive or negative). |
setRate(rate) |
Set playback rate, e.g. 1.5. |
setVolume(volume) |
Set volume in the 0–100 range. |
setMuted(muted) |
Mute or unmute. |
setAspectRatio(ratio) |
'original' | '21:9' | '16:9' | '4:3'. |
setQualities(list, active?) |
Replace the quality list; optionally set active. |
setAudioTracks(list, active?) |
Replace the audio-track list; optionally set active. |
toggleFullscreen() |
Enter / exit browser fullscreen (Fullscreen API). |
toggleWebFullscreen() |
Enter / exit web fullscreen — CSS-only, fills the browser viewport. Works inside iframes without allow="fullscreen". |
togglePip() |
Enter / exit Picture-in-Picture. |
setLoop(loop) |
Toggle loop playback. When on, the browser does not fire ended, so autoNext and plugins depending on ended won't trigger. |
restore(state) |
Apply saved { volume?, muted?, rate?, time? }. Safe to call before metadata loads — the seek waits. See Persistence. |
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. |
on(event, fn) |
Subscribe to an event; returns an unsubscribe function. |
off(event, fn) |
Remove an event listener. |
destroy() |
Tear down the player and release all resources. |
| Event | Payload | Fires when |
|---|---|---|
ready |
— | Metadata is loaded and the player is ready to play. |
play |
— | Playback starts or resumes. |
pause |
— | Playback pauses. |
ended |
— | Playback reaches the end of the video. |
timeupdate |
{ currentTime, duration } |
The current playback position changes. |
ratechange |
number (new rate) |
The playback rate changes. |
volumechange |
{ volume, muted } |
Volume or mute state changes. |
fullscreenchange |
boolean (is fullscreen) |
Browser fullscreen state changes. |
webfullscreenchange |
boolean (is web fullscreen) |
Web fullscreen state changes. |
pipchange |
boolean (is in PiP) |
Picture-in-Picture state changes. |
loopchange |
boolean (is loop on) |
Loop toggle changes. |
aspectratiochange |
AspectRatio |
The forced aspect ratio changes. |
qualitychange |
QualityLevel |
Quality is switched. |
audiotrackchange |
AudioTrackInfo |
Audio track is switched. |
error |
{ type, detail? } |
Playback or network error occurs. |
destroy |
— | destroy() is called. |
player.on('timeupdate', ({ currentTime, duration }) => {});
player.on('error', ({ type, detail }) => {});- Auto mode (default): HLS / DASH multi-level quality/audio tracks auto-populate menus. Selecting "Auto" lets the underlying engine's ABR decide.
- Manual mode: Pass
qualities/audioTracksfor custom lists. Switching triggersonQualityChange/onAudioTrackChangecallbacks. IfQualityLevel.srcis provided, the player auto-switches source while preserving playback position. UsesetQualities()/setAudioTracks()to update at runtime.
| Key | Action |
|---|---|
| Space | Play / Pause |
| ← / → | Seek backward / forward by seekStep seconds |
| Hold ← / → | Accelerating seek (10→30→60 s/s, steps up every 2s, executes on release) |
| ↑ / ↓ | Volume ±5 |
| F | Toggle browser fullscreen |
| W | Toggle web fullscreen (fills the viewport via CSS, works in iframes without allow="fullscreen") |
| M | Toggle mute |
| Esc | Exit web fullscreen |
Click to play/pause · Double-click for fullscreen · Controls auto-hide after 3s during playback · Right-click opens custom context menu (screenshot / video info / shortcuts)
Horizontal swipe to seek · Vertical swipe on right half adjusts volume · Double-tap left/right third to seek backward/forward · Double-tap center for fullscreen · Single tap toggles controls
import type { SweetPlayerPlugin } from '@sweet-player/core';
const myPlugin: SweetPlayerPlugin = {
name: 'my-plugin',
apply(player) {
// player.video / player.container / player.on available
return () => { /* cleanup on destroy */ };
},
};
new SweetPlayer({ ..., plugins: [myPlugin] });
// Or install at runtime: const uninstall = player.use(myPlugin);Plugins can add custom rows to the settings panel via addSettingsRow():
// Toggle row
const remove = player.addSettingsRow({
key: 'danmaku',
label: 'Danmaku',
currentValue: '',
items: [],
onSelect: () => {},
toggle: { checked: true, onToggle: () => toggleDanmaku() },
});
// Select row
player.addSettingsRow({
key: 'theme',
label: 'Theme',
currentValue: 'Default',
items: [
{ label: 'Default', value: '#ff4d6d' },
{ label: 'Blue', value: '#409eff' },
{ label: 'Green', value: '#67c23a' },
],
activeValue: '#ff4d6d',
onSelect: (item) => player.container.style.setProperty('--sp-accent', item.value),
});
remove(); // Remove the row when neededPlugins can add items to the right-click menu via addContextMenuItem():
const remove = player.addContextMenuItem({
label: 'My Action',
onClick: () => { /* ... */ },
}, 1); // index = insertion position
remove(); // Remove when neededsweet-subtitle — Subtitle Plugin
npm install sweet-subtitleimport { SweetSubtitle } from 'sweet-subtitle';
import type { SweetPlayerPlugin } from '@sweet-player/core';
function createSubtitlePlugin(src?: string) {
let sub: SweetSubtitle | null = null;
const plugin: SweetPlayerPlugin = {
name: 'sweet-subtitle',
apply(player) {
sub = new SweetSubtitle(player.video, src ? { src } : {});
return () => { sub?.destroy(); sub = null; };
},
};
return {
plugin,
load: (url: string) => sub?.loadFromUrl(url),
show: () => sub?.show(),
hide: () => sub?.hide(),
setOffset: (s: number) => sub?.setOffset(s),
};
}
const subtitle = createSubtitlePlugin('/subs/ep-01.ass');
const player = new SweetPlayer({ ..., plugins: [subtitle.plugin] });
await subtitle.load('/subs/ep-02.ass');
subtitle.hide();sweet-player-gif — GIF Capture Plugin
npm install sweet-player-gifAdds a "Capture GIF" option to the context menu. Click to capture the last N seconds as a downloadable GIF:
import { SweetPlayerGif } from 'sweet-player-gif';
import type { SweetPlayerPlugin } from '@sweet-player/core';
function createGifPlugin(duration = 3): SweetPlayerPlugin {
return {
name: 'sweet-player-gif',
apply(player) {
const gif = new SweetPlayerGif(player.video, { duration, fps: 10, maxWidth: 480 });
let started = false;
const offPlay = player.on('play', () => {
if (!started) { gif.start(); started = true; }
});
const removeMenu = player.addContextMenuItem({
label: 'Capture GIF',
async onClick() {
if (!started) { gif.start(); started = true; }
const blob = await gif.capture();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `capture-${Date.now()}.gif`;
a.click();
URL.revokeObjectURL(a.href);
},
}, 1);
return () => { offPlay(); removeMenu(); gif.destroy(); };
},
};
}
const player = new SweetPlayer({ ..., plugins: [createGifPlugin(3)] });Without the plugin, the menu item won't appear. See sweet-player-gif docs for all options.
sweet-danmaku — Danmaku (Bullet Comments) Plugin
npm install sweet-danmakuAdds a real-time danmaku (bullet comments) overlay synced with video playback. The built-in plugin factory auto-registers a toggle switch and opacity slider in the settings panel:
import { createDanmakuPlugin } from 'sweet-danmaku';
const danmaku = createDanmakuPlugin({
speed: 1,
area: 0.5,
comments: [
{ text: 'Hello!', time: 1 },
{ text: 'Great scene', time: 5, color: '#ff4d6d' },
],
});
const player = new SweetPlayer({ ..., plugins: [danmaku.plugin] });
// Send live danmaku at runtime
danmaku.send({ text: 'New comment', time: player.video.currentTime });See sweet-danmaku docs for all options (speed, fontSize, area, filter, etc.).
Pass heatmap to show a "most replayed" curve above the progress bar. It appears when you hover the progress bar, and can be toggled from the settings panel.
new SweetPlayer({
container: '#player',
src: '.../video.m3u8',
heatmap: [
{ time: 0, value: 3201 }, // time in seconds; value is any non-negative number
{ time: 5, value: 8850 },
{ time: 10, value: 4120 },
],
});time— seconds; mapped onto the progress bar using the video durationvalue— replay/heat intensity, any non-negative number (normalized internally by the max, no need to pre-scale)- Denser samples produce a smoother, more continuous curve
Typically you fetch aggregated play counts from your backend, then create the player. The response is a plain JSON array where value can be the raw view/replay count per time bucket:
// GET /api/videos/:id/heatmap -> [{ "time": 0, "value": 3201 }, { "time": 5, "value": 8850 }]
const heatmap = await fetch(`/api/videos/${id}/heatmap`).then((r) => r.json());
new SweetPlayer({ container: '#player', src, heatmap });To disable it entirely so none of the curve logic is initialized, add 'heatmap' to hiddenControls.
poster sets a cover image shown before playback starts:
new SweetPlayer({ container: '#player', src, poster: '/poster.webp' });thumbnails shows a preview image when hovering the progress bar. Point it at a WebVTT file where each cue's payload is an image URL, optionally with a #xywh=x,y,w,h fragment to crop a region out of a sprite sheet:
new SweetPlayer({ container: '#player', src, thumbnails: '/thumbs.vtt' });WEBVTT
00:00:00.000 --> 00:00:10.000
sprite.jpg#xywh=0,0,160,90
00:00:10.000 --> 00:00:20.000
sprite.jpg#xywh=160,0,160,90Image URLs in the VTT are resolved relative to the VTT file's own URL. To disable it entirely, add 'thumbnails' to hiddenControls.
The right-click menu has a Copy video URL item that copies a link to the clipboard and flashes a confirmation, just like taking a screenshot. It copies the current page URL (location.href) by default; pass shareUrl to copy a custom link instead:
new SweetPlayer({ container: '#player', src, shareUrl: 'https://example.com/watch?v=123' });You can also trigger it programmatically with player.copyShareUrl().
Hidden Controls
hiddenControls hides specific UI features (all shown by default; only affects UI, not API or shortcuts):
new SweetPlayer({ ..., hiddenControls: ['ratio', 'audioTrack', 'pip'] });Available values:
| Value | Hides |
|---|---|
prev |
Previous button |
seekBack |
Rewind button |
play |
Play / pause button |
seekForward |
Forward button |
next |
Next button |
time |
Current-time / duration label |
rate |
Playback-speed row in the settings panel |
quality |
Quality row in the settings panel |
ratio |
Aspect-ratio row in the settings panel |
audioTrack |
Audio-track row in the settings panel |
volume |
Volume control |
pip |
Picture-in-Picture toggle in the settings panel |
loop |
Loop-playback toggle in the settings panel |
heatmap |
Most-replayed curve above the progress bar |
thumbnails |
Progress-bar hover preview thumbnails |
poster |
Cover image before playback starts |
settings |
Entire settings-panel button |
fullscreen |
Browser fullscreen button |
webFullscreen |
Web fullscreen button |
title |
Top-left title |
progress |
Whole progress bar (also disables heatmap & thumbnails) |
contextMenu |
Custom right-click menu |
Volume, mute and playback rate are remembered globally; resume position is remembered per video when you pass an id.
Both are stored for you by default — nothing to set up:
new SweetPlayer({ container: '#player', src: '...', id: 'video-123' });| Stored in | Survives | |
|---|---|---|
| Preferences | localStorage |
Indefinitely, across tabs and sessions |
| Resume position | sessionStorage |
Reloads only — cleared when the tab closes |
Each uses a single key, so watching a lot of videos never piles up entries. Set persist: false to stop remembering preferences; omit id to stop remembering position.
To store on your own backend instead — needed for resume position that outlives the tab, or for cross-device sync — pass the two callbacks and hand your saved data to restore():
const player = new SweetPlayer({
container: '#player',
src: '...',
id: 'video-123',
onSavePrefs: (prefs) => api.savePrefs(prefs), // { volume, muted, rate }
onSaveProgress: (id, seconds) => api.saveProgress(id, seconds), // null → finished, delete the row
});
player.restore(await api.load('video-123')); // { volume: 80, muted: false, rate: 1.5, time: 220 }That's all you write — throttling, save timing and error handling are internal. restore() can be called at any point, including before the video has loaded. The two callbacks are independent: pass only onSaveProgress and preferences stay in localStorage.
Two independent modes, both toggled from the control bar:
| Browser fullscreen | Web fullscreen | |
|---|---|---|
| Underlying API | Element.requestFullscreen() |
CSS only (position: fixed; inset: 0) |
| Fills | The whole screen | The browser viewport (URL bar / tabs stay visible) |
| Shortcut | F |
W (Esc to exit) |
| Works inside iframes | Requires the parent page to set allow="fullscreen" |
Always works — no permission needed |
If you build a video product that other people embed, prefer web fullscreen: many rich-text editors (WeChat MP, Notion, Yuque, …) strip iframe attributes, breaking the Fullscreen API but never web fullscreen.
https://player.sweetui.com/embed.html is a minimal page that turns URL query parameters into a SweetPlayer instance — drop it into any iframe:
<iframe
src="https://player.sweetui.com/embed.html?src=https://your.host/video.m3u8&autoplay=1"
width="800" height="450" frameborder="0"
allow="autoplay; picture-in-picture"></iframe>Query parameters: src (required), title, poster, thumbnails, autoplay=1, muted=1, locale=zh-CN|en.
The embed page relays play / pause / ended / error back to the host via postMessage({ source: 'sweet-player', type, payload }) — listen with window.addEventListener('message', ...).
- i18n:
locale: 'en'for English;localeStringsto override specific strings;registerLocale(name, strings)to register a full language pack - Theming: Override CSS variables, e.g.
.sweet-player { --sp-accent: #00a1d6; }
MIT