
Build a Reusable Ionic Audio Player with Live Transcript Highlighting in Angular

D. Rout
May 8, 2026 11 min read
On this page
I went looking for a reusable audio player component that could display and auto-scroll a live transcript — highlighting the current spoken line as the audio plays. Something clean, themeable, and accessible. I couldn't find anything that hit all those marks inside an Ionic/Angular stack without pulling in a heavy third-party dependency.
So I built one from scratch. This tutorial walks through every decision, from the data model to the scroll-position bug that tripped me up along the way.
The full source is on GitHub: ionic-angular-audio-player
Prerequisites
Before starting, make sure you have the following installed:
| Tool | Minimum version |
|---|---|
| Node.js | 20+ |
| Angular CLI | 19+ |
| Ionic CLI | 7+ |
| Capacitor (optional) | 6+ |
You should be comfortable with Angular standalone components, ChangeDetectionStrategy.OnPush, and basic Ionic page structure. No third-party audio libraries are required — we use the native HTMLAudioElement API throughout.
Step 1 — Define the Data Models
The component is driven by a typed AudioResource object. The key addition over a plain audio URL is the Segments array: timed transcript entries that power the live-highlight feature.
Create src/app/shared/audio-player/audio-player.component.ts and start with the interfaces:
export interface AudioSegment {
start_time: string; // "HH:MM:SS"
end_time: string | null;
speaker?: string;
text: string;
}
export interface AudioResource {
Id: string;
Title: string;
SubTitle?: string;
AudioURL: string;
CaptionURL?: string; // .vtt file for native <track>
CaptionLang?: string;
CaptionLabel?: string;
Transcript?: string; // flat fallback string
Segments?: AudioSegment[]; // drives live highlight
}
Segments is optional — if absent, the transcript toggle button is simply hidden. Transcript is a flat string fallback for cases where you have the text but no timing data.
Step 2 — Add the Time Utility Functions
Add two small pure functions above the @Component decorator. These handle all time conversion needs without pulling in a library:
/** Parse "HH:MM:SS" or "MM:SS" → seconds */
function timeToSeconds(t: string): number {
if (!t) return 0;
const parts = t.split(':').map(Number);
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
if (parts.length === 2) return parts[0] * 60 + parts[1];
return Number(parts[0]) || 0;
}
/** Seconds → "M:SS" display string */
function secondsToDisplay(s: number): string {
if (!isFinite(s) || s < 0) return '0:00';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
}
Step 3 — Scaffold the Component Class
The component uses ChangeDetectionStrategy.OnPush — important for performance when timeupdate fires up to several times per second.
@Component({
selector: 'app-audio-player',
templateUrl: './audio-player.component.html',
styleUrls: ['./audio-player.component.scss'],
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AudioPlayerComponent implements OnInit, AfterViewInit, OnDestroy {
@Input({ required: true }) audio!: AudioResource;
@ViewChild('audioEl') audioEl!: ElementRef<HTMLAudioElement>;
@ViewChild('transcriptScroll') transcriptScroll?: ElementRef<HTMLElement>;
private cdr = inject(ChangeDetectorRef);
// Playback state
isPlaying = false;
currentTime = 0;
duration = 0;
progressPct = 0;
activeSegIndex = -1;
showTranscript = false;
playbackSpeed = 1;
// Decorative waveform bars
waveformBars = [8, 14, 10, 18, 12, 20, 10, 16, 8, 14, 18, 10, 14, 8, 16];
barDelays = this.waveformBars.map((_, i) => i * 70);
private speeds = [1, 1.25, 1.5, 2, 0.75];
private speedIdx = 0;
private segStartSeconds: number[] = [];
ngOnInit(): void {
// Pre-compute segment start times once, not on every timeupdate tick
if (this.audio.Segments?.length) {
this.segStartSeconds = this.audio.Segments.map(s => timeToSeconds(s.start_time));
}
}
ngOnDestroy(): void {
this.pause();
}
}
Pre-computing segStartSeconds in ngOnInit is a deliberate choice. The timeupdate event fires frequently, so the active-segment lookup runs every tick. Keeping it as a binary search over a pre-built number array keeps that path as lean as possible.
Step 4 — Wire Up the Audio Events
These handlers bridge the native HTMLAudioElement events to component state. Each one calls this.cdr.markForCheck() because we're using OnPush — Angular won't pick up changes automatically.
onLoaded(): void {
this.duration = this.audioEl.nativeElement.duration;
this.cdr.markForCheck();
}
onPlay(): void { this.isPlaying = true; this.cdr.markForCheck(); }
onPause(): void { this.isPlaying = false; this.cdr.markForCheck(); }
onEnded(): void {
this.isPlaying = false;
this.currentTime = 0;
this.progressPct = 0;
this.activeSegIndex = -1;
this.cdr.markForCheck();
}
onTimeUpdate(): void {
const el = this.audioEl.nativeElement;
this.currentTime = el.currentTime;
this.duration = el.duration || this.duration;
this.progressPct = this.duration ? (this.currentTime / this.duration) * 100 : 0;
this.updateActiveSegment();
this.cdr.markForCheck();
}
Step 5 — Implement the Controls
togglePlay(): void {
const el = this.audioEl.nativeElement;
el.paused ? el.play() : el.pause();
}
private pause(): void {
try { this.audioEl?.nativeElement?.pause(); } catch { /* noop on destroy */ }
}
skip(seconds: number): void {
const el = this.audioEl.nativeElement;
el.currentTime = Math.max(0, Math.min(el.currentTime + seconds, el.duration || 0));
}
seek(event: MouseEvent): void {
const track = event.currentTarget as HTMLElement;
const rect = track.getBoundingClientRect();
const ratio = (event.clientX - rect.left) / rect.width;
this.audioEl.nativeElement.currentTime = ratio * (this.audioEl.nativeElement.duration || 0);
}
// Arrow keys and space on the progress slider
onProgressKey(event: KeyboardEvent): void {
switch (event.key) {
case 'ArrowRight': this.skip(5); event.preventDefault(); break;
case 'ArrowLeft': this.skip(-5); event.preventDefault(); break;
case ' ':
case 'Enter': this.togglePlay(); event.preventDefault(); break;
}
}
cycleSpeed(): void {
this.speedIdx = (this.speedIdx + 1) % this.speeds.length;
this.playbackSpeed = this.speeds[this.speedIdx];
this.audioEl.nativeElement.playbackRate = this.playbackSpeed;
}
seekToSegment(seg: AudioSegment): void {
const el = this.audioEl.nativeElement;
el.currentTime = timeToSeconds(seg.start_time);
if (el.paused) el.play();
}
toggleTranscript(): void {
this.showTranscript = !this.showTranscript;
this.cdr.markForCheck();
}
formatTime(s: number): string {
return secondsToDisplay(s);
}
Step 6 — The Active Segment Tracker (with the Scroll Bug Fix)
This is where the interesting logic lives — and where a subtle bug can ruin the experience.
Segment lookup — binary search
private updateActiveSegment(): void {
if (!this.segStartSeconds.length) return;
let lo = 0, hi = this.segStartSeconds.length - 1, idx = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (this.segStartSeconds[mid] <= this.currentTime) {
idx = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
if (idx !== this.activeSegIndex) {
this.activeSegIndex = idx;
this.scrollActiveSegIntoView(idx);
}
}
The scroll bug — offsetTop vs getBoundingClientRect
The initial implementation used segEl.offsetTop to position the scroll. This works in a plain browser page but breaks in Ionic because offsetTop is relative to the element's nearest positioned ancestor — which in Ionic's shadow DOM and ion-content hierarchy is often the page root, not the scroll container. The result: every segment change scrolls straight to the bottom of the panel.
The fix is to use getBoundingClientRect() on both elements, which gives viewport-relative coordinates, then convert to a container-relative scroll offset:
private scrollActiveSegIntoView(idx: number): void {
if (!this.showTranscript || idx < 0) return;
// Small timeout lets Angular render the active CSS class first
setTimeout(() => {
const scrollEl = this.transcriptScroll?.nativeElement;
if (!scrollEl) return;
const segEl = scrollEl.querySelector(
`#seg-${this.audio.Id}-${idx}`
) as HTMLElement | null;
if (!segEl) return;
const containerRect = scrollEl.getBoundingClientRect();
const segRect = segEl.getBoundingClientRect();
// Position relative to the scroll container's content, accounting
// for how far the container is already scrolled
const segOffsetInContainer =
segRect.top - containerRect.top + scrollEl.scrollTop;
// Keep the active line at ~1/3 from the top of the panel
const targetScroll = segOffsetInContainer - scrollEl.clientHeight / 3;
scrollEl.scrollTo({ top: Math.max(0, targetScroll), behavior: 'smooth' });
}, 30);
}
The 30 ms setTimeout gives Angular's change detection one render cycle to apply the .ap-transcript__seg--active class before we query getBoundingClientRect(). Without it, we'd be measuring the position of the previously-highlighted element.
Step 7 — Build the Template
The native <audio> element is visually hidden (but accessible) so the browser's built-in controls don't show. We drive everything from the custom UI layer.
<div class="ap-shell" [class.ap-shell--playing]="isPlaying">
<!-- Header with animated waveform -->
<header class="ap-header">
<div class="ap-waveform" aria-hidden="true">
<span *ngFor="let b of waveformBars; let i = index"
class="ap-waveform__bar"
[style.height.px]="b"
[style.animation-delay]="isPlaying ? (barDelays[i] + 'ms') : '0ms'"
[class.ap-waveform__bar--active]="isPlaying">
</span>
</div>
<div class="ap-header__text">
<h2 class="ap-title">{{ audio.Title }}</h2>
<p class="ap-subtitle" *ngIf="audio.SubTitle">{{ audio.SubTitle }}</p>
</div>
</header>
<!-- Hidden native audio -->
<div class="ap-native-wrap">
<audio #audioEl
[id]="'audio-' + audio.Id"
(timeupdate)="onTimeUpdate()"
(play)="onPlay()" (pause)="onPause()"
(ended)="onEnded()" (loadedmetadata)="onLoaded()"
oncontextmenu="return false;" controlsList="nodownload"
[attr.aria-label]="audio.Title">
<source [src]="audio.AudioURL" type="audio/mpeg">
<track *ngIf="audio.CaptionURL" kind="captions"
[src]="audio.CaptionURL" [srclang]="audio.CaptionLang ?? 'en'"
[label]="audio.CaptionLabel ?? 'English'" default>
</audio>
</div>
<!-- Controls -->
<div class="ap-controls" role="group" [attr.aria-label]="'Controls for ' + audio.Title">
<!-- Progress bar -->
<div class="ap-progress">
<span class="ap-time ap-time--current">{{ formatTime(currentTime) }}</span>
<div class="ap-progress__track"
role="slider"
[attr.aria-valuemin]="0" [attr.aria-valuemax]="duration"
[attr.aria-valuenow]="currentTime"
[attr.aria-valuetext]="formatTime(currentTime) + ' of ' + formatTime(duration)"
tabindex="0"
(click)="seek($event)" (keydown)="onProgressKey($event)">
<div class="ap-progress__fill" [style.width.%]="progressPct"></div>
<div class="ap-progress__thumb" [style.left.%]="progressPct"></div>
</div>
<span class="ap-time ap-time--total">{{ formatTime(duration) }}</span>
</div>
<!-- Button row: skip back, play/pause, skip forward, speed, transcript -->
<div class="ap-btn-row">
<button class="ap-btn ap-btn--skip" (click)="skip(-10)" aria-label="Skip back 10 seconds">
<!-- SVG — see full template in repo -->
</button>
<button class="ap-btn ap-btn--play" (click)="togglePlay()"
[attr.aria-label]="isPlaying ? 'Pause' : 'Play'">
<!-- Play/pause SVG toggled via *ngIf -->
</button>
<button class="ap-btn ap-btn--skip" (click)="skip(10)" aria-label="Skip forward 10 seconds">
<!-- SVG — see full template in repo -->
</button>
<button class="ap-btn ap-btn--speed" (click)="cycleSpeed()"
[attr.aria-label]="'Playback speed: ' + playbackSpeed + 'x'">
{{ playbackSpeed }}×
</button>
<button class="ap-btn ap-btn--transcript"
*ngIf="audio.Segments?.length"
(click)="toggleTranscript()"
[class.ap-btn--transcript-open]="showTranscript"
[attr.aria-expanded]="showTranscript"
[attr.aria-label]="showTranscript ? 'Hide transcript' : 'Show transcript'">
<!-- Document SVG -->
</button>
</div>
</div>
<!-- Transcript panel -->
<div *ngIf="showTranscript && audio.Segments?.length"
class="ap-transcript"
[id]="'transcript-panel-' + audio.Id"
aria-live="polite">
<div class="ap-transcript__header">
<span class="ap-transcript__label">Transcript</span>
<button class="ap-transcript__close" (click)="toggleTranscript()" aria-label="Close transcript">
<!-- X SVG -->
</button>
</div>
<div class="ap-transcript__scroll" #transcriptScroll>
<p *ngFor="let seg of audio.Segments; let i = index"
class="ap-transcript__seg"
[class.ap-transcript__seg--active]="i === activeSegIndex"
[class.ap-transcript__seg--past]="i < activeSegIndex"
[id]="'seg-' + audio.Id + '-' + i"
(click)="seekToSegment(seg)">
<span class="ap-transcript__ts">{{ seg.start_time | slice:3:8 }}</span>
<span class="ap-transcript__text">{{ seg.text }}</span>
</p>
</div>
</div>
</div>
Angular note: use
let i = index(not$index) in*ngFor— the implicit$indexvariable is only available with the newer@forcontrol flow syntax.
Step 8 — Theme with CSS Custom Properties
All colours are declared on :host, making it trivial to retheme per-page without touching the component SCSS:
:host {
--ap-primary: #0b6e6e;
--ap-primary-light: #e6f4f4;
--ap-primary-mid: #1a9898;
--ap-accent: #f0a500;
--ap-surface: #ffffff;
--ap-border: #d6e8e8;
--ap-text: #1a2e2e;
--ap-text-muted: #6b8b8b;
--ap-radius: 12px;
--ap-shadow: 0 2px 16px rgba(11, 110, 110, 0.10);
--ap-transition: 200ms cubic-bezier(0.4, 0, 0.2, 1);
display: block;
}
To match your app's Ionic palette:
// In your page's SCSS file
app-audio-player {
--ap-primary: var(--ion-color-primary);
--ap-primary-light: var(--ion-color-primary-tint);
--ap-primary-mid: var(--ion-color-primary-shade);
}
Step 9 — Integrate into a Page
Drop the audio-player/ folder into src/app/shared/, then update your page:
// my-page.ts
import { AudioPlayerComponent, AudioResource } from 'src/app/shared/audio-player';
@Component({
standalone: true,
imports: [CommonModule, AudioPlayerComponent],
})
export class MyPage {
audioList: AudioResource[] = [ /* your data */ ];
}
<!-- my-page.html -->
<ul class="resource-list">
<li *ngFor="let audio of audioList" class="resource-list__item">
<app-audio-player [audio]="audio"></app-audio-player>
</li>
</ul>
If you need to pause sibling players when one starts, add an @Output() audioPlayed = new EventEmitter<void>() to the component and emit it inside onPlay(). The parent page can then track the active player reference and call .pause() on any others.
Reference: CSS Custom Properties
| Property | Default | Purpose |
|---|---|---|
--ap-primary |
#0b6e6e |
Main accent — buttons, highlights, active border |
--ap-primary-light |
#e6f4f4 |
Hover backgrounds, header gradient, active row fill |
--ap-primary-mid |
#1a9898 |
Progress bar gradient end, active timestamp colour |
--ap-accent |
#f0a500 |
Reserved for secondary accent (e.g. errors, alerts) |
--ap-surface |
#ffffff |
Card and controls background |
--ap-surface-alt |
#f7fafa |
Transcript panel background |
--ap-border |
#d6e8e8 |
All border and divider lines |
--ap-text |
#1a2e2e |
Primary text (title) |
--ap-text-muted |
#6b8b8b |
Secondary text (timecodes, subtitle) |
--ap-text-light |
#a8c0c0 |
Past/inactive transcript segments |
--ap-radius |
12px |
Card and panel border-radius |
--ap-radius-sm |
6px |
Button border-radius |
--ap-shadow |
0 2px 16px … |
Card resting shadow |
--ap-transition |
200ms ease |
All UI transitions |
What's Next
A few directions worth exploring from here:
- Waveform visualisation — connect the
AnalyserNodefrom the Web Audio API to draw a real-time frequency waveform instead of the decorative animated bars. - VTT caption generation — build a Node.js utility that converts a
Segments[]array to a valid.vttfile and passes it to the<track>element, enabling native browser caption rendering as a fallback. - Multi-player coordinator service — extract the pause-sibling logic into an injectable
AudioCoordinatorServiceso any number of players on the same page automatically pause each other without parent-page wiring. - Offline / Capacitor support — use the Capacitor Filesystem plugin to cache the audio file locally and swap the
AudioURLfor acapacitor://URI, allowing the player to work fully offline in a native app build.
Further Reading
- HTMLAudioElement API — MDN Web Docs{:target="_blank"}
- Angular Standalone Components guide{:target="_blank"}
- Angular ChangeDetectionStrategy.OnPush — Angular Docs{:target="_blank"}
- WebVTT: The Web Video Text Tracks Format — MDN{:target="_blank"}
- ARIA: slider role — MDN Web Docs{:target="_blank"}
- Ionic Framework — Component Documentation{:target="_blank"}
The complete working project — including the demo page, sample data, and all component files — is available here on Github. Clone it, run npm install && ionic serve, and you should have the player running locally in under two minutes.
Read next
Comments (0)
Join the conversation
Sign in to leave a comment on this post.
No comments yet. to be the first!