Documentation
Build with UniplayOS
UniplayOS resolves a raw media URL into a playback strategy, streams it through a same-origin proxy when the source needs one, and gives you a player you can either open directly or embed on any page with a couple of lines of script.
Quickstart
Clone the repo, install dependencies, run the dev server.
# clone and install
git clone https://github.com/unitedevz/uniplayOs.git
cd uniplayOs
npm install
# start the dev server on :3000
npm run dev
Then open http://localhost:3000/player.html?url=https://example.com/video.mp4 to test a direct link, or /test.html to inspect how any URL resolves before wiring it up.
Resolver
Every URL passed to the player goes through Resolver.resolve(url) first. It looks at the URL, decides what kind of media it is, and picks a playback strategy plus a fallback chain to try if that strategy fails.
const resolver = new Resolver({ proxyBase: '/proxy' });
const result = resolver.resolve('https://cdn.example.com/stream.m3u8');
console.log(result.type); // 'hls'
console.log(result.strategy); // 'hls'
console.log(result.proxyRequired); // true
console.log(result.fallbackChain); // ['hls', 'proxy', 'mp4-fallback']
What resolve() returns
| Field | Type | Meaning |
|---|---|---|
source | string | The original URL you passed in |
type | string | mp4 · hls · dash · audio · image · iframe · unknown |
strategy | string | How the player should load it: native, proxy, hls, dash, or iframe |
proxyRequired | boolean | True if the strategy is proxy |
headers | object | User-Agent and Referer to send when proxying |
fallbackChain | string[] | Ordered strategies to try if the first one fails |
iframe strategy and skip the proxy entirely, since those platforms serve their own playback surface.Proxy Endpoint
When a source needs to be proxied, the player requests it through /proxy instead of fetching it directly. This is what lets UniplayOS sidestep CORS and hotlink protection on the source, and forwards range requests so seeking still works.
fetch('/proxy?url=' + encodeURIComponent('https://cdn.example.com/clip.mp4'), {
headers: { Range: 'bytes=0-' }
});
Behavior
| Status | Meaning |
|---|---|
200 / 206 | Media streamed through, range requests return partial content |
400 | Missing, invalid or unsupported url (only http/https, no embedded credentials) |
403 | Target resolves to a private or loopback address, or the host isn't on the allowlist |
414 | url longer than 8192 characters |
429 | Per-IP rate limit exceeded — check Retry-After |
422 | Upstream returned HTML instead of media — usually a dead or blocked link |
502 | Upstream fetch failed |
504 | Upstream took longer than 15 seconds |
Target validation
Loopback, private, link-local, carrier-grade NAT and IPv6 unique-local ranges (including IPv4-mapped and NAT64 forms) are blocked. Every redirect hop is validated again, and the address check also runs at connect time, so a hostname that resolves differently between the check and the request is still rejected.
CF_WORKER_URL in .env to route proxy requests through a Cloudflare Worker first, for sources that block by IP range. See the UniplayOsproxy repo for the worker setup.HLS & DASH manifest rewriting
When the proxied URL is an HLS playlist (.m3u8) or a DASH manifest (.mpd), the proxy doesn't just stream it through — it rewrites every segment, sub-playlist, encryption key, alternate audio/subtitle track, I-frame playlist, low-latency part, and BaseURL reference inside it to route back through /proxy too. data: key URIs are left untouched. Without this, a manifest could load fine while its individual segments still hit the original (possibly blocked) host directly.
Templated DASH segments
DASH SegmentTemplate URLs contain placeholders such as $Number$, $Time$ and $RepresentationID$ that the DASH client fills in itself, so they can't be wrapped in a query string. The proxy rewrites them to a path form instead:
token is the base64url-encoded segment directory and rest is the remainder of the template, placeholders included. Once the client substitutes the placeholders, the server joins the two and fetches the segment. This route runs the same checks as /proxy: blocked private hosts, the host allowlist and the rate limit. A malformed or oversized token returns 400.
References are resolved through the nested BaseURL chain, as the DASH spec requires, and <Location> elements are rewritten so live manifest refreshes stay on the proxy. UTCTiming URLs are not proxied.
Caching & retries
Rewritten manifests are cached in memory for a few seconds per source URL, so repeated requests for the same live manifest don't refetch upstream every time. Check the X-Proxy-Cache response header (HIT or MISS) to see whether a given response came from cache. Failed upstream requests (network errors or a 5xx response) are retried once before the proxy gives up. Non-200 manifest responses are passed through uncached.
Player Params
The player at /player.html reads its source straight from the query string, so it can be deep-linked without any JavaScript.
| Param | Example | Description |
|---|---|---|
url | ?url=https://…/video.mp4 | Media source to load on page open |
source | ?source=https://… | Alias used by the embed script |
sources | ?sources=["https://…/a.mp4","https://…/b.mp4"] | URL-encoded JSON playlist; items can be strings or objects with src, source or url |
autoplay | ?autoplay=true | Starts playback immediately, muted where browsers require it |
subtitles | ?subtitles=[{"src":"https://…/en.srt","lang":"en","label":"English"}] | URL-encoded JSON list of external subtitle files; items can be strings or objects with src (or url), lang, label and default |
downloads | ?downloads=false | Hides the download button |
theme | ?theme=light | dark (default), light, or auto to follow the system setting. The video area stays black, and fullscreen controls keep the dark overlay |
debug | ?debug=true | Shows the status/debug bar under the controls |
Playlists
With more than one entry in sources (plus source, which sets the starting item), the player shows previous/next buttons and moves to the next item when one ends. Each change fires a trackchange event to the embedding page.
Subtitles
A CC button appears whenever subtitle tracks are available. Tracks come from three places: external files passed in subtitles, subtitle tracks declared in an HLS manifest, and text tracks in a DASH manifest. External files can be SRT or WebVTT; SRT is converted in the browser, and cross-origin files are fetched through /proxy. In a playlist, an item object can carry its own subtitles list, while the top-level subtitles attach to the starting item.
The last language you pick is remembered, and so is turning subtitles off. A remembered language wins over a track marked default, and a remembered Off wins over both. YouTube and Vimeo sources have their own caption controls and don't show the CC button.
Quality selector
HLS and DASH streams with more than one quality get a quality button next to the speed control. Auto lets the player adapt and shows the current level; picking a resolution pins it. Manual choices are remembered and re-applied to the closest level at or below the saved height. Plain files, YouTube and Vimeo don't show the button.
Keyboard shortcuts and gestures
Shortcuts work whenever the player has focus and you aren't typing in a field. Combinations with Ctrl, Cmd or Alt are left to the browser, and YouTube and Vimeo sources are driven through their own players, so only native, HLS and DASH playback is guaranteed.
| Key | Action |
|---|---|
Space / K | Play or pause |
← / → | Seek 5 seconds back or forward |
J / L | Seek 10 seconds back or forward |
↑ / ↓ | Volume up or down by 5% |
M | Mute or unmute |
F | Toggle fullscreen |
I | Toggle picture-in-picture |
C | Toggle subtitles (re-selects the last track used) |
< / > | Slower or faster playback |
0–9 | Jump to 0%–90% of the video |
Home / End | Jump to the start or the end |
N / P | Next or previous playlist item |
| Gesture | Action |
|---|---|
| Tap the video | Play or pause |
| Double-tap the left third | Rewind 10 seconds |
| Double-tap the right third | Skip forward 10 seconds |
| Double-tap the middle | Toggle fullscreen |
Picture-in-picture
When the browser supports it, native, HLS and DASH playback show a picture-in-picture button next to fullscreen. The player sends a pictureinpicture message with { active } when it opens or closes. Embeds created with embed.js already allow it. If you write the iframe yourself, add picture-in-picture to its allow attribute. There's no embed method for it, because browsers only start picture-in-picture from a direct click inside the player.
Media controls
For native, HLS and DASH playback the player registers with the browser's Media Session API, so lock-screen and notification controls, media keys and Bluetooth headsets can play, pause, seek and move between playlist items. The title comes from the file name and the artist from the source host.
Downloading HLS and DASH streams
The download button in the player and the downloads page work for HLS and DASH too. The segments are fetched through /proxy, decrypted if needed, and joined into a file. In the player, the quality you picked is used (Auto takes the highest). On the downloads page it always takes the highest.
| Stream | Result |
|---|---|
| HLS with MPEG-TS segments | One .ts file; convert with ffmpeg -i file.ts -c copy file.mp4 |
| HLS with fMP4 segments | One .mp4 file |
| HLS or DASH with separate audio | Two files, name.video.mp4 and name.audio.m4a; merge with ffmpeg -i video -i audio -c copy out.mp4 |
| DASH | A video file and an audio file |
Chromium-based browsers (Chrome, Edge) save straight to disk through a save dialog, or a folder picker when there are two files, so size is not limited. Other browsers assemble the file in memory, up to 2 GB. Rate-limited segments are retried after the proxy's Retry-After delay, and the button turns into Cancel while a download runs.
Not supported: live streams, DRM-protected content, HLS encryption other than AES-128, and DASH streams with more than one period. Encrypted HLS needs a secure (HTTPS or localhost) page for the Web Crypto API.
Embed Script
Drop the embed script into any page and point it at a container element. It mounts an iframe running the UniplayOS player and talks to it over postMessage.
<div id="player" style="width:800px;height:450px;"></div>
<script type="module">
import UniplayOS from "https://www.uniplayos.web.id/embed.esm.js";
const player = new UniplayOS({
container: "#player",
source: "https://example.com/video.mp4",
autoplay: false,
debug: false,
onReady() { console.log("ready"); },
onPlay() { console.log("playing"); },
onError(err) { console.error(err); }
});
</script>
Constructor Options
| Option | Default | Description |
|---|---|---|
container | '#uniplayos' | CSS selector for the element the player mounts into |
source | null | Single media URL to load |
sources | [] | Playlist of URLs (or objects with src/source/url), passed through as JSON. source picks the starting item |
width / height | '100%' / '500px' | Applied to the container element |
autoplay | false | Starts playback on load |
subtitles | [] | External subtitle files for the current source, as strings or { src, lang, label, default } objects |
theme | 'dark' | 'dark', 'light' or 'auto' |
allowDownloads | true | Shows the download control in the player UI; false hides it |
debug | false | Shows the resolver/status debug bar |
Events
Pass any of these as callbacks in the constructor. Each fires from a postMessage the player sends back to the parent page.
| Callback | Fires when |
|---|---|
onReady | The player has mounted and is ready to receive commands |
onPlay | Playback starts |
onPause | Playback pauses |
onEnded | Playback reaches the end of the source |
onError | Every playback strategy has failed, or the media errors after it was ready |
onTimeUpdate | Playback position changes; receives the player's time payload |
onTrackChange | The playlist moves to another item; receives { index, source, total } |
onQualityLevels | An HLS/DASH stream's qualities are known; receives { heights }, highest first |
onQualityChange | The selected or live quality changes; receives { auto, height } |
onSubtitleTracks | The list of subtitle tracks changes; receives { tracks: [{ lang, label }] } |
onSubtitleChange | The active subtitle track changes; receives { lang, label }, both null when off |
Instance Methods
Once you hold a reference to the player instance, drive it programmatically.
player.play();
player.pause();
player.togglePlay();
player.seek(30);
player.setVolume(0.5);
player.toggleMute();
player.toggleFullscreen();
player.load('https://example.com/next.mp4', [{ src: 'https://example.com/next.en.srt', lang: 'en', label: 'English' }]);
player.next();
player.previous();
player.setQuality(720);
player.setQuality('auto');
player.setSubtitle('en');
player.setSubtitle('off');
player.setPlaybackRate(1.5);
player.setDebug(true);
player.destroy();
Supported Formats
Environment
| Variable | Required | Description |
|---|---|---|
PORT | No | Server port, defaults to 3000 |
CF_WORKER_URL | No | Cloudflare Worker URL for the optional egress-proxy bypass layer |
PROXY_RATE_LIMIT_MAX | No | Max proxy requests per IP per window, defaults to 300 |
PROXY_RATE_LIMIT_WINDOW_MS | No | Rate limit window in milliseconds, defaults to 60000 |
PROXY_ALLOWED_HOSTS | No | Comma-separated host allowlist (e.g. .example.com,cdn.example.org); empty allows any public host |