UniplayOS
GitHub Resolver Test Launch Player

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

Result shape
FieldTypeMeaning
sourcestringThe original URL you passed in
typestringmp4 · hls · dash · audio · image · iframe · unknown
strategystringHow the player should load it: native, proxy, hls, dash, or iframe
proxyRequiredbooleanTrue if the strategy is proxy
headersobjectUser-Agent and Referer to send when proxying
fallbackChainstring[]Ordered strategies to try if the first one fails
Note — YouTube and Vimeo links always resolve to the 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.

GET /proxy?url=<encoded media url>
fetch('/proxy?url=' + encodeURIComponent('https://cdn.example.com/clip.mp4'), {
  headers: { Range: 'bytes=0-' }
});

Behavior

Response codes
StatusMeaning
200 / 206Media streamed through, range requests return partial content
400Missing, invalid or unsupported url (only http/https, no embedded credentials)
403Target resolves to a private or loopback address, or the host isn't on the allowlist
414url longer than 8192 characters
429Per-IP rate limit exceeded — check Retry-After
422Upstream returned HTML instead of media — usually a dead or blocked link
502Upstream fetch failed
504Upstream 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.

Optional — set 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:

GET /proxy/t/<token>/<rest>

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.

Query parameters
ParamExampleDescription
url?url=https://…/video.mp4Media 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=trueStarts 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=falseHides the download button
theme?theme=lightdark (default), light, or auto to follow the system setting. The video area stays black, and fullscreen controls keep the dark overlay
debug?debug=trueShows 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.

Keyboard
KeyAction
Space / KPlay or pause
← / →Seek 5 seconds back or forward
J / LSeek 10 seconds back or forward
↑ / ↓Volume up or down by 5%
MMute or unmute
FToggle fullscreen
IToggle picture-in-picture
CToggle subtitles (re-selects the last track used)
< / >Slower or faster playback
0–9Jump to 0%–90% of the video
Home / EndJump to the start or the end
N / PNext or previous playlist item
Mouse and touch
GestureAction
Tap the videoPlay or pause
Double-tap the left thirdRewind 10 seconds
Double-tap the right thirdSkip forward 10 seconds
Double-tap the middleToggle 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.

What you get
StreamResult
HLS with MPEG-TS segmentsOne .ts file; convert with ffmpeg -i file.ts -c copy file.mp4
HLS with fMP4 segmentsOne .mp4 file
HLS or DASH with separate audioTwo files, name.video.mp4 and name.audio.m4a; merge with ffmpeg -i video -i audio -c copy out.mp4
DASHA 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

new UniplayOS(options)
OptionDefaultDescription
container'#uniplayos'CSS selector for the element the player mounts into
sourcenullSingle 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
autoplayfalseStarts 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'
allowDownloadstrueShows the download control in the player UI; false hides it
debugfalseShows 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 options
CallbackFires when
onReadyThe player has mounted and is ready to receive commands
onPlayPlayback starts
onPausePlayback pauses
onEndedPlayback reaches the end of the source
onErrorEvery playback strategy has failed, or the media errors after it was ready
onTimeUpdatePlayback position changes; receives the player's time payload
onTrackChangeThe playlist moves to another item; receives { index, source, total }
onQualityLevelsAn HLS/DASH stream's qualities are known; receives { heights }, highest first
onQualityChangeThe selected or live quality changes; receives { auto, height }
onSubtitleTracksThe list of subtitle tracks changes; receives { tracks: [{ lang, label }] }
onSubtitleChangeThe 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

Video
mp4 · webm · mkv · avi · mov · m4v · ts · flv · wmv · 3gp
Audio
mp3 · wav · flac · aac · ogg · m4a · wma · opus
Streaming
HLS (.m3u8) · DASH (.mpd)
Iframe
YouTube · Vimeo
Not supported — TikTok, Instagram and Facebook serve signed, dynamically-loaded video URLs that can't be resolved without a headless browser or an extractor like yt-dlp.

Environment

Variables
VariableRequiredDescription
PORTNoServer port, defaults to 3000
CF_WORKER_URLNoCloudflare Worker URL for the optional egress-proxy bypass layer
PROXY_RATE_LIMIT_MAXNoMax proxy requests per IP per window, defaults to 300
PROXY_RATE_LIMIT_WINDOW_MSNoRate limit window in milliseconds, defaults to 60000
PROXY_ALLOWED_HOSTSNoComma-separated host allowlist (e.g. .example.com,cdn.example.org); empty allows any public host