The browser's
controlsattribute, but actually good. A plug-based, reactive HTML5 media player.
Live Demo | Showcase | Report Bug | Discussions
You already have this:
<video controls src="movie.mp4"></video>Add one script tag and change 3 characters:
<script src="https://cdn.jsdelivr.net/npm/tmg-media-player@latest" defer></script>
<video tmgcontrols src="movie.mp4"></video>That's the whole install. No bundler. No npm install. No framework.
What you get out of the box:
- Fullscreen, Theater, Picture-in-Picture, Miniplayer (all modes, all wired)
- Timeline scrubbing with hover preview thumbnails
- 30+ keyboard shortcuts that match YouTube conventions
- Touch & wheel gestures for volume, brightness, and scrubbing
- Captions with full WebVTT support: live font, size, color, opacity, and edge-style customization
- Playlist management with auto-next countdowns
- HLS / DASH / YouTube / Vimeo with source auto-detection, no extra config
- Media Session API for OS-level controls on mobile & desktop
- Screen capture to image
- Voice commands that are fully configurable per action
- Settings persistence via any storage
- 100% accessible with full ARIA, keyboard-first, focus-managed interactions, and much more...
The browser's native controls are a blunt tool. TMG is the upgrade.
<!DOCTYPE html>
<html lang="en">
<head>
<script src="https://cdn.jsdelivr.net/npm/tmg-media-player@latest" defer></script>
</head>
<body>
<video tmgcontrols src="movie.mp4" poster="poster.jpg"></video>
</body>
</html>npm install tmg-media-playerimport "tmg-media-player";The script self-initializes. Any <video tmgcontrols> or <audio tmgcontrols> on the page is picked up automatically.
The fastest way to configure is directly on the element using tmg-- attributes. The convention maps to a nested config object where double dashes (--) are path separators and single dashes (-) are word separators within a key.
tmg--settings--overlay--behavior--value="persistent"
└── settings └── overlay └── behavior └── value
<!-- Video title shown in the player UI and OS media controls -->
<video tmgcontrols
tmg--media--settings--metadata--title="Justice League Teaser"
tmg--media--settings--metadata--artist="Warner Bros."
src="movie.mp4"
poster="poster.jpg">
<!-- Native <track> elements work exactly as-is -->
<track default kind="subtitles" srclang="en" src="subtitles.vtt" label="English" />
<track kind="chapters" srclang="en" src="chapters.vtt" />
</video><!-- Timeline hover preview thumbnails, $ is replaced by the frame number -->
<video tmgcontrols
tmg--settings--control-panel--timeline--previews--address="previews/frame$.jpg"
tmg--settings--control-panel--timeline--previews--spf="10"
src="movie.mp4">
</video><!-- Overlay behavior: "auto" hides on play, "persistent" always shows -->
<video tmgcontrols
tmg--settings--overlay--behavior--value="auto"
src="movie.mp4">
</video><!-- Rearrange or trim the control panel layout -->
<video tmgcontrols
tmg--settings--control-panel--bottom[2]="playPause,volume,timeAndDuration,spacer,captions,settings,fullscreen"
src="movie.mp4">
</video><!-- Load an entire JSON config file or a JSON string -->
<video tmg="./player-config.json" src="movie.mp4"></video>
<video tmg='{"settings":{"overlay":{"behavior":{"value":"persistent"}}}}' src="movie.mp4"></video><!-- Debug mode -->
<video tmgcontrols tmg--debug="true" src="movie.mp4"></video>For programmatic control, use tmg.Player. The API covers three operations: attach, configure, and detach.
const player = new tmg.Player();
player.configure({
settings: {
controlPanel: {
timeline: {
previews: { address: "previews/frame$.jpg", spf: 10 }, // or { address: "preview/frame-sprite.jpg", cols: 5, rows: 5 }
tooltip: true,
},
},
overlay: { behavior: { value: "auto" } },
errors: {
2: "Network dropped. Check your connection.",
4: "This format isn't supported by your browser.",
},
},
});
// Attach to any video or audio element
await player.attach(document.querySelector("video"));
// Detach and restore the element to its original state
player.detach();player.configure() can be called multiple times before attach(). Configs are deep-merged, last write wins per key. The same shape accepted by configure() is also accepted as a JSON file via tmg="config.json" on the element.
Pass a playlist array in your config. Each entry is a media item with its own source, metadata, and tracks:
const player = new tmg.Player();
player.configure({
playlist: [
{
src: "episode-1.mp4",
media: {
intent: {
poster: "ep1-poster.jpg",
},
settings: {
metadata: {
title: "Episode 1: Pilot",
artist: "TMG Studios",
artwork: [{ src: "ep1-poster.jpg" }],
},
},
},
tracks: [
{ default: true, kind: "subtitles", srclang: "en", src: "ep1-en.vtt", label: "English" },
],
},
{
src: "episode-2.mp4",
media: {
settings: { metadata: { title: "Episode 2: The Return" } },
},
},
],
});
await player.attach(document.querySelector("video"));Playlists support shuffle, loop, ads, and per-item start/end times.
Integrate VAST ad tags seamlessly using the ads.rolls array. You can schedule pre-rolls, mid-rolls, and post-rolls using exact times (in seconds) or percentages:
const player = new tmg.Player();
player.configure({
ads: {
rolls: [
{
time: 0, // Pre-roll
url: "https://pubads.g.doubleclick.net/gampad/ads?...output=vast",
badge: "Advertisement",
"media.settings.metadata.artist": "Sponsored Brand",
"media.settings.metadata.links.title": "https://brand.com/"
},
{
time: "50%", // Mid-roll at 50%
url: "https://pubads.g.doubleclick.net/gampad/ads?...output=vast"
},
{
time: -5, // Post-roll 5 seconds before the end
url: "https://pubads.g.doubleclick.net/gampad/ads?...output=vast"
}
]
}
});
await player.attach(document.querySelector("video"));Override CSS variables on any ancestor element, or globally on :root:
:root {
--tmg-media-brand-color: #e63946; /* Accent color for timeline, buttons, and highlights */
--tmg-media-font-family: "Inter", sans-serif;
--tmg-media-background: #0a0a0a;
--tmg-media-theme-color: #1a1a1a; /* Controls surface background */
}Or pass CSS values as config:
player.configure({
settings: {
css: {
brandColor: "#e63946",
fontFamily: "'Inter', sans-serif",
},
},
});Or via HTML attribute:
<video tmgcontrols tmg--settings--css--brand-color="#e63946" src="movie.mp4"></video>Because TMG loads techs and plugins asynchronously, you should wait for the controller to be fully initialized before interacting with it programmatically.
The controller avoids flooding the DOM with events (that's what the Reactor is for!). Instead, it only fires a few critical lifecycle events on the media element, which correspond directly to the internal ctlr.state.readyState ladder:
tmgcreate(ReadyState 0): The controller is attached, but plugins are not yet connected.tmginit(ReadyState 1): All DOM elements are mounted, but reactive listeners are deferred.tmgwire(ReadyState 2): Reactive listeners are wired and active. The controller is now fully safe to use.tmgplay(ReadyState 3): Fired the first time the user interacts or plays the media.tmgdestroy: Fired when the controller is destroyed and cleans up its memory.
video.addEventListener("tmgwire", (e) => {
const ctlr = e.detail.ctlr; // The payload contains the controller instance!
console.log("TMG is wired and ready!");
ctlr.config; // your configurations now live here and are fully reactive
ctlr.media; // media is extracted from the build to live here standalone
ctlr.media.intent.volume = 50; // Safe to use the reactor now
});TMG Media Player is not just a bunch of UI components mutating a <video> tag. It is a proof of concept for sia-reactor, a high-performance programmable data engine.
Every piece of data flows through a transactional pipeline divided strictly into State (the factual truth) and Intent (a request to change the truth).
You do not write to the player's state. You write to its Intent.
const { media } = video.tmgPlayer.controller;
video.play(); // ❌ WRONG: Do not touch the video directly
media.intent.paused = false; // ✅ RIGHT: Request a change through IntentWhen you dispatch an Intent, the active Media Tech (e.g. HTML5 Video, YouTube, HLS) or a higher-power plugin intercepts it, validates it, performs the necessary asynchronous operations, and finally updates the State to confirm the change.
The ctlr.media object is the unified API for interacting with the player, regardless of what video format or tech is currently loaded. It is divided into 4 reactive spheres:
intent- Volatile properties you write to when you want something to happen (media.intent.paused = false,media.intent.volume = 50).state- The active, confirmed truth of the player (media.state.paused,media.state.currentTime).status- Read-only facts derived directly from the media element.settings- Writable configurations that affect the player's underlying behavior (also handled as Intents).
Because S.I.A. intercepts changes before they settle, plugins can effortlessly reject, modify, or clamp intents. For example, if you send media.intent.volume = 200, the Volume Limits plugin intercepts it during the capture phase and clamps the final state down to 100 before the UI ever renders it.
Note: For the full list of supported properties across all spheres, always refer directly to the source of truth:
contract.d.ts(for the.mediaobject) andschema.d.ts(for.configand.state).
TMG exposes the raw power of sia-reactor directly to the outside world. You can observe, watch, or intercept state easily.
const { media } = video.tmgPlayer.controller;
// Watch fires immediately with current value, and on every change
media.watch("state.paused", ({ value }) => console.log(value ? "Video is paused" : "Video is playing"));
// Intercept memory writes (Mediator phase)
media.set("intent.volume", (val) => Math.min(val, 50));
// Reject or Claim an intent (Capture phase)
media.on("intent.playing", (e) => (!userHasPremium)&& e.reject(), { capture: true }); // Cancel the intent completelyBecause TMG is built on S.I.A., syncing its state with React is entirely decoupled from the DOM. You don't need to listen to messy HTML5 <video> events or worry about race conditions.
sia-reactor ships with a native React adapter that provides hooks like useSelector and usePath for concurrent-safe, surgically precise re-renders.
import { useReactor } from "sia-reactor/adapters/react";
function PlayButton({ ctlr }) {
const media = useReactor(ctlr.media); // Subscribes directly to the underlying S.I.A reactor state using an autotracking proxy
return (
<button onClick={() => (media.intent.paused = !media.state.paused)}>
{media.state.paused ? "Play" : "Pause"}
</button>
);
}For more advanced selector logic across multiple paths, you can use useSelector(ctlr.media, () => ...) or read the full documentation.
TMG is infinitely extensible. Features like Volume, Playback Rate, Captions, and even the core Control Panel are just Plugs.
You can add your own custom logic by writing a Plug and registering it before the player initializes. Reference BasePlug for more info.
import { BasePlug, PlugRegistry } from "tmg-media-player";
class MyCustomPlug extends BasePlug {
public static readonly plugName = "custom";
public static readonly isMain = false; // main live on `config` root otherwise `settings` branch
public static readonly BUILD = {}; // your build will be attached to default config at registration
public override wire() {
// Ctlr Media Listeners
this.media.on("state.currentTime", (e) => {
if (e.value > 60) console.log("Past 1 minute!");
}, { signal: this.signal }); // Automatically cleans up when destroyed
// Post Wiring
super.wire(); // registers menu if applicable
}
}
PlugRegistry.register(MyCustomPlug);You can access any running plug dynamically from the controller to access its internal APIs:
// Access the underlying Captions plug
const captionsPlug = controller.plug("settings.captions"); // paths are the map keys and this isn't main hence the `settings.` prefix
captionsPlug.settings.css.currentCaptionsY = "50px"; The system uses dedicated registries (PlugRegistry, ComponentRegistry, TechRegistry) so you can inject or rip out entire chunks of the player architecture effortlessly. For a deep dive on how to structure Plugs, Components, and Techs, refer to our Architecture documentation on GitHub.
If you decide to replace the default icons with your own custom SVGs (via the IconRegistry) and it detects that your SVGs share the same underlying structure (the same sequence of drawing commands), it will automatically interpolate them during state changes (e.g. Play to Pause, Enter to Exit Fullscreen).
| Browser | Support |
|---|---|
| Chrome 90+ | ✅ Full |
| Edge 90+ | ✅ Full |
| Firefox 88+ | ✅ Full |
| Safari 14+ | |
| Mobile Chrome | ✅ Full |
| Mobile Safari |
- Architect & Developer: Oketade Oluwatobiloba (Tobi007-del)
- Project: tmg-media-player
Built as the media engine for The Movie Garden initiative. Powered internally by sia-reactor, a high-performance State & Intent Architecture engine that makes the entire plug system reactive, transactional, and infinitely extensible.
If this is useful, a star means a lot ⭐