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

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

D. Rout

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 $index variable is only available with the newer @for control 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:

  1. Waveform visualisation — connect the AnalyserNode from the Web Audio API to draw a real-time frequency waveform instead of the decorative animated bars.
  2. VTT caption generation — build a Node.js utility that converts a Segments[] array to a valid .vtt file and passes it to the <track> element, enabling native browser caption rendering as a fallback.
  3. Multi-player coordinator service — extract the pause-sibling logic into an injectable AudioCoordinatorService so any number of players on the same page automatically pause each other without parent-page wiring.
  4. Offline / Capacitor support — use the Capacitor Filesystem plugin to cache the audio file locally and swap the AudioURL for a capacitor:// URI, allowing the player to work fully offline in a native app build.

Further Reading


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.

Share

Comments (0)

Join the conversation

Sign in to leave a comment on this post.

No comments yet. to be the first!