HLS Video Streaming in Ionic Capacitor with hls.js — A Reusable Angular Component
Tutorials Ionic Angular Capacitor hls.js Video Streaming

HLS Video Streaming in Ionic Capacitor with hls.js — A Reusable Angular Component

D. Rout

D. Rout

May 8, 2026 9 min read

On this page

Introduction

If you've ever tried to play HLS (.m3u8) streams inside an Ionic Capacitor app, you've probably hit the first wall fast: it's not one platform, it's four. iOS native, Android native, Chrome, and Safari all behave differently when it comes to HLS playback. This tutorial walks you through building a single reusable Angular component that handles all four cases correctly, using hls.js on platforms that need it and native HLS where it's already supported.

By the end of this tutorial, you'll have a production-ready HlsVideoComponent that you can drop into any Ionic page, point at an .m3u8 URL from any CDN, and get reliable cross-platform video streaming — plus a VideoService that loads your video manifest from a remote JSON file on your CDN.

The full working sample project is on GitHub: deepakrout/ionic-hls-video-player

Prerequisites

  • Node.js >= 18 and npm >= 9
  • Ionic CLI: npm install -g @ionic/cli
  • A basic Ionic Angular standalone project (ionic start my-app blank --type=angular)
  • Capacitor already set up (@capacitor/core and @capacitor/cli installed)
  • An HLS stream (.m3u8) hosted on any CDN (Azure, CloudFront, GCP CDN, Cloudflare, etc.)
  • Basic familiarity with Angular standalone components

Step 1: Understanding the Platform Matrix

Before writing a single line of code, it helps to know why you can't just slap a <video src="stream.m3u8"> and call it a day:

Platform

MSE Support

Native HLS

Strategy

iOS native (Capacitor)

No

Yes (Safari WKWebView)

Set video.src directly — skip hls.js

Android native (Capacitor)

Yes (Chromium WebView)

No

Use hls.js via MSE

Web — Chrome / Firefox

Yes

No

Use hls.js via MSE

Web — Safari (desktop)

Partial

Yes

Native HLS fallback via canPlayType

The key is using Capacitor.getPlatform() and Hls.isSupported() to branch cleanly at runtime.

Step 2: Install hls.js

Install hls.js into your Ionic project:

npm install hls.js
npm install --save-dev @types/hls.js

hls.js ships with its own TypeScript types in modern versions, but installing @types/hls.js adds explicit IDE support for older setups. Either way, you get full type coverage.

Step 3: Build the HlsVideoComponent

Create the component files:

src/app/components/hls-video/
  hls-video.component.ts
  hls-video.component.html
  hls-video.component.scss

hls-video.component.ts

import {
  Component, Input, OnDestroy, AfterViewInit,
  ElementRef, ViewChild,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Capacitor } from '@capacitor/core';
import Hls, { ErrorData } from 'hls.js';

@Component({
  selector: 'app-hls-video',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './hls-video.component.html',
  styleUrls: ['./hls-video.component.scss'],
})
export class HlsVideoComponent implements AfterViewInit, OnDestroy {
  @Input() src!: string;
  @Input() poster?: string;
  @Input() autoplay = false;
  @Input() muted = false;

  @ViewChild('videoEl') videoRef!: ElementRef<HTMLVideoElement>;

  private hls: Hls | null = null;

  ngAfterViewInit(): void {
    this.initPlayer();
  }

  private initPlayer(): void {
    const video = this.videoRef.nativeElement;
    const platform = Capacitor.getPlatform();
    const isNative = Capacitor.isNativePlatform();

    // iOS: WKWebView has native HLS — hls.js not needed
    if (isNative && platform === 'ios') {
      video.src = this.src;
      video.load();
      if (this.autoplay) video.play();
      return;
    }

    // Android + Web: use hls.js if MSE is available
    if (Hls.isSupported()) {
      this.hls = new Hls({
        maxBufferLength: 30,
        maxMaxBufferLength: 60,
        startLevel: -1, // auto ABR
      });
      this.hls.loadSource(this.src);
      this.hls.attachMedia(video);
      this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
        if (this.autoplay) video.play();
      });
      this.hls.on(Hls.Events.ERROR, (_e, data: ErrorData) => {
        if (data.fatal) this.handleFatalError(data);
      });
      return;
    }

    // Safari desktop fallback
    if (video.canPlayType('application/vnd.apple.mpegurl')) {
      video.src = this.src;
      if (this.autoplay) video.play();
    }
  }

  private handleFatalError(data: ErrorData): void {
    switch (data.type) {
      case Hls.ErrorTypes.NETWORK_ERROR:
        this.hls?.startLoad();
        break;
      case Hls.ErrorTypes.MEDIA_ERROR:
        this.hls?.recoverMediaError();
        break;
      default:
        this.hls?.destroy();
    }
  }

  ngOnDestroy(): void {
    this.hls?.destroy();
    this.hls = null;
  }
}

hls-video.component.html

<div class="hls-video-wrapper">
  <video
    #videoEl
    [poster]="poster || ''"
    [muted]="muted"
    controls
    playsinline
    preload="metadata"
    aria-label="HLS Video Player">
    Your browser does not support HTML5 video.
  </video>
</div>

The playsinline attribute is critical on iOS — without it, mobile Safari forces the video into fullscreen automatically, which breaks the layout inside Ionic pages.

hls-video.component.scss

.hls-video-wrapper {
  display: block;
  width: 100%;
  background: #000;
  border-radius: 8px;
  overflow: hidden;

  video {
    display: block;
    width: 100%;
    height: auto;
    max-height: 480px;
    object-fit: contain;
  }
}

Step 4: Build the VideoService

Rather than hardcoding video URLs in your component, fetch a JSON manifest from your CDN at runtime. This means you can update your video library without touching the app code.

First, make sure HttpClient is provided in your app config:

// app.config.ts
import { provideHttpClient } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(),
    // ...other providers
  ],
};

Then create the service:

// src/app/services/video.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';

export interface Video {
  title: string;
  description: string;
  videoUrl: string;
  thumbnailUrl?: string;
}

@Injectable({ providedIn: 'root' })
export class VideoService {
  private readonly CDN_MANIFEST_URL =
    'https://your-cdn.example.com/data/videos.json';

  constructor(private http: HttpClient) {}

  getVideos(): Observable<Video[]> {
    return this.http.get<Video[]>(this.CDN_MANIFEST_URL).pipe(
      catchError(err => {
        console.error('Failed to load video manifest', err);
        return throwError(() => err);
      })
    );
  }
}

Your CDN should serve a JSON file (not a .ts file — see the note below) at the manifest URL. The shape should match the Video interface above:

[
  {
    "title": "Introduction to Angular Signals",
    "description": "Learn how Angular Signals simplify reactive state.",
    "videoUrl": "https://your-cdn.example.com/videos/signals/stream.m3u8",
    "thumbnailUrl": "https://your-cdn.example.com/thumbnails/signals.jpg"
  }
]
Why JSON, not TypeScript? Angular's build toolchain (webpack/esbuild) resolves import statements at compile time from local paths or node_modules only. You cannot import a .ts file from a remote URL at runtime. Serving a .json file and fetching it via HttpClient is the correct pattern for dynamic remote data.

Step 5: Wire It Into a Page

// home.page.ts
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonHeader, IonToolbar, IonTitle, IonContent, IonSpinner } from '@ionic/angular/standalone';
import { VideoService, Video } from '../../services/video.service';
import { HlsVideoComponent } from '../../components/hls-video/hls-video.component';

@Component({
  selector: 'app-home',
  standalone: true,
  imports: [CommonModule, IonHeader, IonToolbar, IonTitle, IonContent, IonSpinner, HlsVideoComponent],
  templateUrl: './home.page.html',
})
export class HomePage implements OnInit {
  videos: Video[] = [];
  loading = true;
  error: string | null = null;

  constructor(private videoService: VideoService) {}

  ngOnInit(): void {
    this.videoService.getVideos().subscribe({
      next: data => { this.videos = data; this.loading = false; },
      error: () => { this.error = 'Failed to load videos.'; this.loading = false; },
    });
  }
}
<!-- home.page.html -->
<ion-header>
  <ion-toolbar>
    <ion-title>Video Tutorials</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
  <div *ngIf="loading">
    <ion-spinner name="crescent"></ion-spinner>
  </div>

  <p *ngIf="error">{{ error }}</p>

  <div *ngFor="let video of videos" class="video-card">
    <app-hls-video
      [src]="video.videoUrl"
      [poster]="video.thumbnailUrl || ''">
    </app-hls-video>
    <h2>{{ video.title }}</h2>
    <p>{{ video.description }}</p>
  </div>
</ion-content>

Step 6: Configure Your CDN and Capacitor

CDN CORS Headers

Without these, the Android WebView will silently fail on MSE byte-range requests:

Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, HEAD
Access-Control-Expose-Headers: Content-Range, Accept-Ranges

CDN Content-Type Headers

Serve the correct MIME types for HLS segments:

File Extension

Content-Type

.m3u8

application/vnd.apple.mpegurl

.ts

video/MP2T

.json

application/json

capacitor.config.ts

const config: CapacitorConfig = {
  appId: 'io.habitualcs.hlsvideoplayer',
  appName: 'HLS Video Player',
  webDir: 'www',
  server: {
    androidScheme: 'https',
  },
  android: {
    allowMixedContent: false, // always use HTTPS CDN
  },
};

Step 7: Build, Sync, and Run

# Build Angular
ionic build

# Sync to native platforms
npx cap sync

# Run on iOS simulator
npx cap open ios

# Run on Android
npx cap open android

For live reload during development:

ionic cap run ios --livereload --external
ionic cap run android --livereload --external

hls.js Error Codes Reference

Error Type

Constant

Cause

Recovery

Network

NETWORK_ERROR

CDN unreachable, CORS failure, timeout

hls.startLoad() to retry

Media

MEDIA_ERROR

Corrupt segment, codec mismatch

hls.recoverMediaError()

Mux

MUX_ERROR

Demux failure in TS segment

Destroy and recreate instance

Other

OTHER_ERROR

Manifest parse failure, key load error

hls.destroy() — show user error

What's Next

  • Quality selector UI — expose hls.levels and let the user manually switch between 360p, 720p, and 1080p renditions instead of relying on auto-ABR
  • Offline downloads — combine with the Capacitor Filesystem plugin to cache HLS segments locally for offline playback
  • Picture-in-picture — use the Web PiP API (video.requestPictureInPicture()) for persistent background playback on supported platforms
  • Custom video controls — replace native browser controls with a fully custom Angular overlay for branded UI consistency across platforms

Further Reading

Closing

You now have a lean, reusable HlsVideoComponent that streams HLS from any CDN across all four Capacitor/browser targets, a VideoService that loads your video manifest dynamically from JSON, and the CDN configuration checklist to make it all work in production. The full project with realistic file structure, all component files, a sample videos.json, and setup instructions is available at github.com/deepakrout/ionic-hls-video-player. Clone it, swap in your CDN URL, and you're streaming.

Share

Comments (0)

Join the conversation

Sign in to leave a comment on this post.

No comments yet. to be the first!