चलच्चित्रम् · ANGULAR 22+

@code_with_sachin/ngx-video-js

Video.js 10 web components as one standalone Angular player. SSR-safe.

INSTALL

BASH
npm i @code_with_sachin/ngx-video-js @videojs/html

@videojs/html — declared as a peer dependency, so you control the version.

Why

Video.js 10 ships its player as custom elements — <video-player> holds the state, <video-skin> is the packaged UI, a plain <video> handles the media. Those elements are framework-agnostic and already do everything: controls, menus, hotkeys, gestures, seek and volume indicators, thumbnail previews, Cast, AirPlay, picture-in-picture. Rebuilding that as Angular components would produce the same DOM from code you then have to maintain.

So this package is the thin Angular layer over them, and nothing more: signal inputs instead of attributes, the right media element picked from the URL, no custom element registration on the server, and no stylesheet to remember to import.

  • SSR-safe by construction — registering elements no-ops without customElements.
  • Skin CSS lives in the skin's own shadow root, so nothing leaks into your app.
  • Definitions register once per document, however many players you render.

Requirements

TESTED AGAINST

NAMETYPEDEFAULTNOTES
@angular/core^22.0.0 22.0.5 Standalone APIs, signal inputs and afterNextRender are all required.
typescript~6.0.0 6.0.3 Whatever your Angular version supports.
node>=20 24.15.0 Build and SSR only.
@videojs/html^10.0.0-beta.27 10.0.0-beta.27 The custom elements the player wraps. Still beta, so the range is pinned tight.

Peer ranges are wider than this — the table lists the exact versions the demos on this page are running, so you have a known-good combination to fall back on.

Providers

Nothing to register. This package exposes no provider function and reads no injection token — import the component where you use it and you are done. Your app.config.ts needs no change.

Using NgModules

Everything here is standalone, but standalone components and directives are importable from an @NgModule — put them in the module's imports, not declarations. No importProvidersFrom is needed: @NgModule.providers is typed Array<Provider | EnvironmentProviders>, so the provide*() functions drop straight in.

TS · app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { NgxVideoPlayer } from '@code_with_sachin/ngx-video-js';

import { AppComponent } from './app.component';

@NgModule({
  declarations: [AppComponent],
  // Standalone components and directives go in `imports`.
  imports: [
    BrowserModule,
    NgxVideoPlayer,
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

Quick start

Standalone component — import it where you use it. No module, no provider, no stylesheet.

TS · talk.component.ts
import { Component } from '@angular/core';
import { NgxVideoPlayer, type VideoTextTrack } from '@code_with_sachin/ngx-video-js';

@Component({
  selector: 'app-talk',
  imports: [NgxVideoPlayer],
  template: `
    <ngx-video-player
      src="https://stream.mux.com/BV3…/highest.mp4"
      poster="https://image.mux.com/BV3…/thumbnail.webp"
      posterAlt="Cyclist riding past a warehouse"
      [tracks]="tracks"
    />
  `,
})
export class Talk {
  readonly tracks: VideoTextTrack[] = [
    { src: '/captions/en.vtt', srclang: 'en', label: 'English', default: true },
    { src: '/captions/hi.vtt', srclang: 'hi', label: 'हिन्दी' },
  ];
}

Playground

Every input below maps to a template binding, and the three variables at the bottom are plain CSS custom properties that inherit into the skin's shadow root. Drive the player with the keyboard while you are here — the hotkeys are part of the skin, not this page.

Cyclist riding past a warehouse at dusk

Keyboard: space or k play · m mute · c captions · f fullscreen · j/l seek 10s · 09 jump. Double-tap the left or right third to seek. All of it ships inside the skin.

HTML
<ngx-video-player
  src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4"
  poster="https://image.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/thumbnail.webp"
  posterAlt="Cyclist riding past a warehouse at dusk"
  placeholder="https://image.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/thumbnail.webp?width=32"
  crossOrigin="anonymous"
  [tracks]="tracks"
/>

// in the component
readonly tracks: VideoTextTrack[] = [
  {"src":"https://image.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/storyboard.vtt","kind":"metadata","label":"thumbnails","default":true},
];

<style>
  ngx-video-player {
    aspect-ratio: 16 / 9;
    --media-accent-color: #f4771f;
    --media-accent-text-color: #ffffff;
    --media-border-radius: 28px;
    --media-scale-unit: 16px;
    --media-object-fit: contain;
    --media-object-position: center;
    --media-poster-placeholder-blur: 20px;
  }
</style>
SOURCE URL
PROVIDER

Detected video from the URL. hls uses <hlsjs-video>, so the quality and audio menus fill in.

UI LANGUAGE

Every label, tooltip and status message in the skin. The pack loads on demand — pick one and watch the network tab. Empty follows the page's own lang attribute.

SKIN

A different element pair, not a mode: live puts a LIVE pill where the duration was and turns the time slider into the DVR window. These samples are recordings, so the pill sits at the edge and stays grey.

STREAM TYPE

Only the live skin renders anything for this. Ignored by native <video> — set it on an hls or dash source.

SKIN PARTS

Each one is an @if in the ejected skin, so an unchecked box means the element is not in the DOM — there is no rule to override and nothing to switch back on. Removing a control also removes its keyboard and gesture equivalents: drop the progress bar and seeking stops working entirely.

The packaged skins keep their UI in a shadow root and expose no switches. Touch any box below and the player swaps to skin="custom", which is where these apply.

LAYERS
PRIMARY ROW
SETTINGS MENU
SECONDARY ROW
INPUT LAYER
CONTROLS

Controls hide themselves when the media says it cannot do the thing, which is the only lever the skin gives you. disableRemotePlayback removes AirPlay and Cast. disablePictureInPicture blocks the browser's own auto-PiP but does not remove the skin's PiP button in 10.0.0-beta.27 — watch it stay put. Both are read when the media attaches, so they remount.

TEXT TRACKS

A track on another origin needs crossorigin below, or the browser drops it. Editing the list remounts the player: adding a track to a live media element makes @videojs/html 10 beta enable one more of the existing tracks too, so two languages end up drawn over each other.

PRELOAD
CROSSORIGIN
FLAGS

Poster and the skin variables apply live. Source, provider, tracks, preload, crossorigin and autoplay are read when the media element loads, so those remount the player.

SKIN VARIABLES
--media-object-fit
--media-object-position

Inputs

<NGX-VIDEO-PLAYER>

NAMETYPEDEFAULTNOTES
srcstringRequired. Set on the inner <video>. MP4, or HLS where the browser plays it natively.
provider'auto' | 'video' | 'hls' | 'dash' | 'youtube' | 'vimeo' 'auto' Which media element plays the source. auto reads the URL — host for embeds, extension for manifests.
tracksVideoTextTrack[] [] Subtitles, captions, chapters and thumbnails. Ignored by the iframe embeds.
skin'video' | 'live' | 'custom' 'video' video and live are the packaged skins; custom is the ejected one that reads controls.
controlsVideoControls {} Which parts of the ejected skin render, each a real @if. Only skin="custom" reads it.
langstring '' BCP-47 code for the control labels — 51 packs ship, loaded on demand. Empty follows the page.
streamType'' | 'on-demand' | 'live' '' live swaps the duration for a LIVE pill. Empty lets the player infer it from the media.
posterstring '' Rendered as an <img> in the skin poster slot — you own the tag, so srcset works.
posterAltstring '' Alt text for that image. Leave empty when the poster is decorative.
placeholderstring '' Tiny image blurred behind the poster while it loads. Maps to placeholdersrc.
preload'none' | 'metadata' | 'auto' 'metadata' none pairs with a poster for the cheapest first paint.
crossOrigin'' | 'anonymous' | 'use-credentials' '' Cross-origin .vtt tracks need 'anonymous', or the browser drops them.
autoplayboolean false Browsers block it unless muted is set too. Read at load, so it needs a remount.
mutedboolean false Bound as a property, so it is live.
loopboolean false
disableRemotePlaybackboolean false Removes the AirPlay and Cast controls. Read when the media attaches, so set it before the player mounts.
disablePictureInPictureboolean false Blocks the browser’s own auto-PiP and context menu. Does not remove the skin’s PiP button — see Removing controls.

playsinline is always set — without it iOS Safari takes the video fullscreen the moment it plays.

Sources

One component, five media elements. The right one is picked from the URL, and only that element is downloaded — a page playing MP4 never fetches hls.js.

HTML
<!-- provider defaults to "auto", which reads the URL:
     youtube.com / youtu.be      -> <youtube-video>   (iframe embed)
     vimeo.com                   -> <vimeo-video>     (iframe embed)
     …m3u8                       -> <hlsjs-video>
     …mpd                        -> <dash-video>
     anything else               -> <video>            -->
<ngx-video-player src="https://youtu.be/aqz-KE-bpKQ" />
<ngx-video-player src="https://stream.mux.com/lhn….m3u8" />
<ngx-video-player src="/media/talk.mp4" />

<!-- Force it when the URL carries no hint — a signed manifest, a
     content-negotiating endpoint, a proxy that strips the extension. -->
<ngx-video-player provider="hls" src="https://cdn.example.com/stream?token=…" />

PROVIDER · ELEMENT · WHAT AUTO MATCHES

NAMETYPEDEFAULTNOTES
video<video> anything else The browser’s own element. MP4, WebM — and HLS on Safari, which plays it natively.
hls<hlsjs-video> …m3u8 Full hls.js, so the quality and audio-track menus fill in. Works everywhere, not just Safari.
dash<dash-video> …mpd MPEG-DASH manifests.
youtube<youtube-video> youtube.com, youtu.be An iframe embed. The skin drives it, but text tracks and PiP belong to YouTube.
vimeo<vimeo-video> vimeo.com Same iframe caveats as YouTube.

Captions, chapters and thumbnails

All three are ordinary <track> elements, so they are one input. The skin picks each apart by kind: subtitles and captions become the language list in the settings menu, a chapters track divides the time slider, and a metadata track labelled thumbnails feeds the hover preview.

TS
// One array covers every kind of <track> the skin reads.
readonly tracks: VideoTextTrack[] = [
  // Subtitles and captions fill the settings menu; kind defaults to 'subtitles'.
  { src: '/captions/en.vtt', srclang: 'en', label: 'English', default: true },
  { src: '/captions/hi.vtt', srclang: 'hi', label: 'हिन्दी' },
  { src: '/captions/es.vtt', srclang: 'es', label: 'Español' },

  // Divides the time slider and names the chapter under the pointer.
  { src: '/chapters.vtt', kind: 'chapters', default: true },

  // label:"thumbnails" is the magic pair the hover preview looks for.
  { src: 'https://image.mux.com/BV3…/storyboard.vtt', kind: 'metadata', label: 'thumbnails' },
];

VIDEOTEXTTRACK

NAMETYPEDEFAULTNOTES
srcstringRequired. URL of the WebVTT file.
kind'subtitles' | 'captions' | 'descriptions' | 'chapters' | 'metadata' 'subtitles' subtitles and captions populate the settings menu.
srclangstringBCP-47 code. The spec requires one on subtitles and captions.
labelstringWhat the menu shows. Use the endonym — हिन्दी, not Hindi.
defaultboolean false Starts enabled. Chapters and thumbnail tracks need it to be read at all.

Set the array once, before the media element loads, and it is exact. Adding a track to a player that is already running currently makes @videojs/html 10 beta enable one more of the existing tracks alongside it, so two languages draw over each other — reproducible with a plain appendChild, nothing to do with Angular. Until that settles, rebuild the player behind an @if when the list has to change mid-session.

HTML
<!-- A .vtt on another origin is a cross-origin fetch: without this
     the browser drops the track and the captions menu stays empty. The
     server has to send Access-Control-Allow-Origin too. -->
<ngx-video-player
  crossOrigin="anonymous"
  src="https://cdn.example.com/talk.mp4"
  [tracks]="tracks"
/>

What it renders

Worth knowing, because it is what you inspect when something looks wrong — and what you would write by hand if you dropped this wrapper.

HTML
<!-- What the component renders. Three layers: state, packaged
     UI, media — and only the media element changes with the provider. -->
<video-player>
  <video-skin placeholdersrc="…">
    <video src="…" playsinline>
      <track src="/captions/en.vtt" kind="subtitles" srclang="en" label="English" default />
    </video>
    <img slot="poster" src="…" alt="…" />
  </video-skin>
</video-player>

<!-- provider="hls" swaps that one line for <hlsjs-video>, "dash" for
     <dash-video>, "youtube" for <youtube-video>. Nothing else moves. -->

Removing controls

The skin has no per-button switches, and it does not need many: each control asks the media whether it can do the thing and hides itself when the answer is no. That is why the quality and audio menus are missing on a progressive MP4, and why AirPlay never appears outside Safari. The one lever you hold is telling the media it cannot.

HTML
<!-- Removes AirPlay and Cast. -->
<ngx-video-player disableRemotePlayback src="/media/talk.mp4" />

<!-- Blocks the browser's own automatic picture-in-picture and its
     context-menu entry. The skin's PiP button is NOT removed by this
     in 10.0.0-beta.27 — see the note below. -->
<ngx-video-player disablePictureInPicture src="/media/talk.mp4" />

disableRemotePlayback genuinely removes AirPlay and Cast — the availability watch rejects and both buttons go unsupported. disablePictureInPicture does not currently do the same for PiP: 10.0.0-beta.27 derives that button's availability from the browser's capability alone, so the attribute stops the browser's own auto-PiP but leaves the skin's button in place, throwing if pressed. Both are read when the media element attaches, so set them before the player mounts.

The custom skin

skin="custom" renders the default video skin ejected into this package's own Angular template, in light DOM, with every part behind an @if. A part you switch off is not in the document at all — there is no CSS rule for a viewer to override in devtools and no hidden button to re-enable.

TS · lesson.component.ts
import { Component } from '@angular/core';
import { NgxVideoPlayer, type VideoControls } from '@code_with_sachin/ngx-video-js';

@Component({
  imports: [NgxVideoPlayer],
  template: `
    <ngx-video-player skin="custom" [controls]="controls" src="/media/module-3.mp4" />
  `,
})
export class Lesson {
  // Everything defaults to true. These four are gone from the DOM — and with
  // the progress bar go every seeking shortcut, so the restriction holds.
  readonly controls: VideoControls = {
    progressBar: false,
    settingsMenu: false,
    pipButton: false,
    fullscreenButton: false,
  };
}

Removing a control also removes the keyboard and gesture shortcuts that do its job. Drop the progress bar and /, j/l, Home/End, 09, the double-tap seek and the seek indicator go with it — otherwise the restriction would be decoration. The same applies to the submenus: closing the settings menu takes </> with it, because a shortcut whose control is unreachable is a way back in.

VIDEOCONTROLS · ALL DEFAULT TO TRUE

NAMETYPEDEFAULTNOTES
posterlayerThe poster image shown until playback starts.
bufferingIndicatorlayerThe spinner shown while waiting for data.
errorDialoglayerThe dialog shown when playback fails. Removing it leaves errors silent.
overlaylayerThe gradient scrim behind the control bar.
controlBarprimaryThe whole bar. False removes both rows.
playButtonprimaryAlso removes Space and k.
muteButtonprimaryAlso removes m and the volume arrows.
volumeSliderprimaryThe popover above the mute button.
currentTimeprimary
progressBarprimaryThe seek bar. Also removes every seeking shortcut and the seek indicator.
chaptersprimaryChapter segmentation of the seek bar.
thumbnailPreviewprimaryThe thumbnail that follows the pointer.
remainingTimeprimaryClick toggles remaining and duration.
captionsButtonprimaryAlso removes c, unless the submenu remains.
settingsMenumenuThe gear and its panels.
qualityMenumenu
audioMenumenu
speedMenumenuAlso removes < and >.
captionsMenumenu
castButtonsecondary
airplayButtonsecondary
pipButtonsecondaryAlso removes i.
fullscreenButtonsecondaryAlso removes f and the double-tap centre.
hotkeysinputAll keyboard shortcuts at once.
gesturesinputAll tap and double-tap gestures at once.
tooltipsinputHover and focus tooltips on every button.
statusAnnouncerinputScreen-reader announcements. Leave this on.
volumeIndicatorinputThe bar shown while the volume changes.
statusIndicatorinputThe captions/fullscreen/PiP flash.
seekIndicatorinputThe chevrons shown when double-tapping to seek.

Containers collapse on their own: turn off all four buttons in the second row and the row goes, turn off every submenu and the settings trigger goes with them. Changes apply live — it is an Angular template, not a custom element, so nothing remounts. The trade is that the skin's stylesheet travels with the component rather than inside a shadow root, which is about 25 kB gzipped in whichever chunk imports the player, and that you no longer get upstream design updates for free.

Language

51 locale packs ship with @videojs/html, and only the one in use is fetched. Setting lang translates every label, tooltip, menu entry and screen-reader announcement in the skin — there is nothing to register and no strings to maintain.

HTML
<!-- 51 packs ship with @videojs/html; only the one in use is fetched.
     Every label, tooltip, menu entry and status message follows. -->
<ngx-video-player lang="hi" src="/media/talk.mp4" />

<!-- Leave it empty and the player follows the nearest lang attribute,
     so a site that already sets <html lang> needs nothing here. -->
<ngx-video-player src="/media/talk.mp4" />

Live streams

Live is a second packaged skin — <live-video-player> and <live-video-skin> — not a switch on the on-demand one, which has no live controls in its template at all. Setting skin="live" swaps the pair and keeps the media, the tracks and everything projected into it.

HTML
<!-- The live controls are a separate packaged skin, not a mode of the
     on-demand one: a LIVE pill that turns red at the edge where the
     duration would be, and a DVR window on the time slider. -->
<ngx-video-player skin="live" src="https://cdn.example.com/live.m3u8" />

<!-- An endless manifest already reports itself as live, so streamType is
     only for media that cannot say so — a proxy that reports a finite
     duration, a simulated live event, a recording played as live. -->
<ngx-video-player skin="live" streamType="live" src="https://cdn.example.com/dvr.m3u8" />

Anything else

The skin's own hotkeys and gestures are fixed — space, k, m, f, c, i, j/l, arrows, 09, </>, plus tap and double-tap. What you can do is add more, and add the sibling components Video.js expects, by projecting them as children. They are not inputs on this component because there is nothing to wrap: they are elements, and the player context already reaches them.

HTML
<!-- Children land beside the media inside the player, which is where
     Video.js expects its sibling components. Register the element
     yourself — this package only pulls in the skin and one media
     element. -->
<ngx-video-player src="/media/talk.mp4">
  <!-- import '@videojs/html/ui/hotkey' -->
  <media-hotkey keys="n" action="seekStep" value="30"></media-hotkey>
  <media-gesture type="doubletap" action="toggleMuted" region="center"></media-gesture>

  <!-- import '@videojs/html/media/google-cast' and '.../mux-data' -->
  <google-cast></google-cast>
  <mux-data env-key="..."></mux-data>
</ngx-video-player>

ACTIONS A HOTKEY OR GESTURE CAN FIRE

NAMETYPEDEFAULTNOTES
togglePausedhotkey · gesturePlay or pause.
toggleMutedhotkey · gesture
toggleFullscreenhotkey · gesture
toggleSubtitleshotkey · gestureSwitches the caption track on and off.
togglePictureInPicturehotkey · gesture
toggleControlsgestureWhat a single tap does on touch.
seekStephotkey · gesture value: seconds Negative seeks backward.
volumeStephotkey · gesture value: 0–1
speedUp / speedDownhotkey · gestureWalks the playback-rate list.
seekToPercenthotkey value: 0–100 Without a value, the digit key that fired it decides — 0–9 becomes 0–90%.

Bindings are additive — the skin's own are still there, so a second binding on a key it already uses fires twice.

Sizing

CSS
/* <video-player> is display:contents and <video-skin> is a
   full-width grid, so the player takes its size from the host. */
ngx-video-player {
  max-width: 60rem;
  aspect-ratio: 16 / 9;
}

Theming

Custom properties inherit through shadow boundaries, so setting them on the host reaches the skin without any piercing selector.

CSS
ngx-video-player {
  --media-accent-color: oklch(0.72 0.19 25);  /* slider fill, focus, hover */
  --media-border-radius: 0.75rem;
  --media-object-fit: cover;                  /* contain by default */
  --media-scale-unit: 18px;                   /* scales the whole control layer */
}

CUSTOM PROPERTIES

NAMETYPEDEFAULTNOTES
--media-accent-color<color> oklch(1 0 0) Slider fill, focus ring, hover wash.
--media-border-radius<length> 1.75rem Container corners. 0 squares the player off.
--media-object-fitcontain | cover contain Applies to the video and the poster together.
--media-scale-unit<length> 16px Base unit for the whole control layer — raise it for a chunkier player.
--media-object-position<position> center Which part survives the crop when object-fit is cover.
--media-accent-text-color<color> auto Label colour on hovered and focused controls. Derived from the accent unless set.
--media-poster-placeholderurl() none What the placeholder input sets for you.
--media-poster-placeholder-blur<length> 20px How hard the placeholder is blurred.

Recipes

Three shapes cover most of what a page needs from a video.

Cheap above-the-fold player

PRELOAD

A poster plus preload='none' means one image request on load and no media traffic until someone presses play. The blurred placeholder covers the gap while the poster itself downloads.

HTML
<!-- A poster plus preload="none" is the cheapest above-the-fold
     player: one image, no media request until the viewer presses play. -->
<ngx-video-player
  preload="none"
  src="https://stream.mux.com/BV3…/highest.mp4"
  poster="https://image.mux.com/BV3…/thumbnail.webp"
  placeholder="https://image.mux.com/BV3…/thumbnail.webp?width=32"
  posterAlt="Cyclist riding past a warehouse"
/>

Background hero clip

AUTOPLAY

Muted, looping and cover-fit. Autoplay without muted is blocked by every browser, and both are read when the media element loads — change them behind an @if so the player remounts.

HTML
<!-- Muted, looping, cover-fit: a background clip that still
     carries the full control layer if a viewer reaches for it. -->
<ngx-video-player autoplay muted loop preload="auto" src="/media/loop.mp4" />

<style>
  ngx-video-player { height: 60vh; --media-object-fit: cover; }
</style>

Start the element chunk early

ROUTING

The component registers what it needs on its own. Calling the same function in a resolver just moves that work off the player's critical path — it is memoised per module, and a no-op on the server, so the resolver is safe either way.

TS
import { loadVideoJsElements } from '@code_with_sachin/ngx-video-js';

// Registration is memoised per module and a no-op without customElements, so
// this is safe under SSR and just moves the element chunk off the player's
// critical path. Name the provider to warm hls.js alongside the skin.
export const routes: Routes = [
  {
    path: 'watch/:id',
    resolve: { _elements: () => loadVideoJsElements('hls') },
    loadComponent: () => import('./watch').then((m) => m.Watch),
  },
];

Server rendering

TS · app.routes.server.ts
// app.routes.server.ts — this very page is prerendered.
export const serverRoutes: ServerRoute[] = [
  { path: 'packages/video', renderMode: RenderMode.Prerender },
];

// The component emits <video-player><video-skin><video> on the server and
// never touches customElements. The elements upgrade on hydration; until
// then the browser paints the poster <img>.
//
// The media element is chosen from the URL, so it is already the right tag
// in the prerendered HTML — no swap on hydration.

EXPORTS

NAMETYPEDEFAULTNOTES
loadVideoJsElements(provider?)(VideoProvider) => Promise<unknown> ('video') Registers the skin and one media element. Memoised per module and a no-op without customElements, so a route resolver is safe under SSR.
detectProvider(src)(string) => VideoProviderThe rule behind provider="auto". Exported so you can show or override the guess.

Notes

Beta

@videojs/html is at 10.0.0-beta.27 — "close to stable" by its own README, but the element API can still move, and adding text tracks to a running player mis-selects (see above). The peer range is pinned tight for that reason; widen it once v10 ships.

Embeds are not players

YouTube and Vimeo render an iframe the skin drives from outside. Playback, seeking and volume work; text tracks, picture-in-picture and AirPlay stay with the embed, and their menu entries go unavailable. Everything else here is a real media element.

Packaged, not ejected

You get the default skin and its CSS variables. When you need to restructure the UI rather than restyle it, eject the skin per the Video.js guide and compose the media-* elements in your own template — CUSTOM_ELEMENTS_SCHEMA is all Angular needs.

Bundle

The skin and the one media element you need arrive as dynamic imports, so nothing lands in your initial chunk and hls.js is only downloaded by a page that streams HLS. A page with no player pays nothing.

Built for sachinsingh.me — this package ships from that portfolio's own workspace.

MIT · Sachin Singh