Skip to content

Repository files navigation

English | 中文

Sweet Player

npm version npm downloads GitHub stars license live demo

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

Scope

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.

Install

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>.

Quick Start

Vanilla JS

import { SweetPlayer } from '@sweet-player/core';

const player = new SweetPlayer({
  container: '#player',
  src: 'https://example.com/video.m3u8',
});

CDN / Script Tag

<script src="https://unpkg.com/@sweet-player/core/dist/sweet-player.global.js"></script>
<script>
  const player = new SweetPlayer({ container: '#player', src: '...' });
</script>

React

npm install @sweet-player/react
import { 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);

Vue

npm install @sweet-player/vue
<SweetPlayer :src="src" title="..." id="ep-01" @ready="p => (player = p)" @next="..." />

Options

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
});

API

Methods

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.

Events

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 }) => {});

Quality / Audio Tracks

  • 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 / audioTracks for custom lists. Switching triggers onQualityChange / onAudioTrackChange callbacks. If QualityLevel.src is provided, the player auto-switches source while preserving playback position. Use setQualities() / setAudioTracks() to update at runtime.

Controls

Keyboard

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

Mouse

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)

Touch

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

Plugin System

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);

Extend Settings Panel

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 needed

Extend Context Menu

Plugins 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 needed

sweet-subtitle — Subtitle Plugin

npm install sweet-subtitle
import { 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-gif

Adds 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-danmaku

Adds 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.).

Heatmap (Most replayed)

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 duration
  • value — 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 & Preview Thumbnails

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,90

Image URLs in the VTT are resolved relative to the VTT file's own URL. To disable it entirely, add 'thumbnails' to hiddenControls.

Copy Video URL

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

Persistence

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.

Fullscreen — browser vs. web

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.

Embed page

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', ...).

Customization

  • i18n: locale: 'en' for English; localeStrings to override specific strings; registerLocale(name, strings) to register a full language pack
  • Theming: Override CSS variables, e.g. .sweet-player { --sp-accent: #00a1d6; }

License

MIT

About

Framework-agnostic HLS & DASH video player with full UI. Plugin system, screenshot, thumbnails.

Topics

Resources

Stars

29 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages