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

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/coreand@capacitor/cliinstalled) - 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 |
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 |
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) resolvesimportstatements at compile time from local paths ornode_modulesonly. You cannotimporta.tsfile from a remote URL at runtime. Serving a.jsonfile and fetching it viaHttpClientis 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 |
| CDN unreachable, CORS failure, timeout |
|
Media |
| Corrupt segment, codec mismatch |
|
Mux |
| Demux failure in TS segment | Destroy and recreate instance |
Other |
| Manifest parse failure, key load error |
|
What's Next
- Quality selector UI — expose
hls.levelsand 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
- hls.js API Documentation
- Capacitor Development Workflow — Official Docs
- Apple HTTP Live Streaming Overview
- MDN: Media Source Extensions API
- Ionic Angular — Your First App
- web.dev: Media Buffering and Playback
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.
Read next
Comments (0)
Join the conversation
Sign in to leave a comment on this post.
No comments yet. to be the first!