Menu

Change language

GitHub repo

Podcast Audio Player

blockmedia

Spotify and Apple Podcasts style audio player with 32-bar visual SVG waveform scrubber, chapter markers, playback speed controls, and show notes accordion.

Installation

$npx uipkge-ng@latest add podcast-audio-player
Manifest:https://uipkge.dev/r/angular/podcast-audio-player.jsonInstalls to:src/app/components/blocks/podcast-audio-player/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

PodcastChapter
interface PodcastChapter {
  id: number
  title: string
  subtitle: string
  start: number
  end: number
  durationFormatted: string
}
PodcastPanelist
interface PodcastPanelist {
  name: string
  role: string
  company: string
  avatar: string
  fallback: string
  bio: string
  handle: string
}
PodcastReferenceLink
interface PodcastReferenceLink {
  title: string
  description: string
  url: string
  tag: string
}

Files installed (2)

  • src/app/components/blocks/podcast-audio-player/podcast-audio-player.component.ts39.1 kB
    // Spotify / Apple Podcasts style audio player: episode hero card, 32-bar visual SVG
    // waveform scrubber with chapter markers, transport controls with speed cycling,
    // chapter navigation grid, and show-notes accordion. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, DestroyRef, Input, computed, inject, signal } from '@angular/core'
    import {
      BookOpen,
      Bookmark,
      BookmarkCheck,
      Check,
      Clock,
      Copy,
      Download,
      ExternalLink,
      FastForward,
      Headphones,
      Heart,
      ListMusic,
      LucideAngularModule,
      Mic,
      Pause,
      Play,
      Radio,
      RotateCcw,
      RotateCw,
      Share2,
      SkipBack,
      SkipForward,
      Volume1,
      Volume2,
      VolumeX,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import {
      UiAccordionComponent,
      UiAccordionContentComponent,
      UiAccordionItemComponent,
      UiAccordionTriggerComponent,
    } from '@/ui/accordion/accordion.component'
    import { UiAvatarComponent, UiAvatarFallbackComponent, UiAvatarImageComponent } from '@/ui/avatar/avatar.component'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardFooterComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiProgressComponent } from '@/ui/progress/progress.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import { UiSliderComponent } from '@/ui/slider/slider.component'
    
    export interface PodcastChapter {
      id: number
      title: string
      subtitle: string
      start: number
      end: number
      durationFormatted: string
    }
    
    export interface PodcastPanelist {
      name: string
      role: string
      company: string
      avatar: string
      fallback: string
      bio: string
      handle: string
    }
    
    export interface PodcastReferenceLink {
      title: string
      description: string
      url: string
      tag: string
    }
    
    const waveformBars = [
      32, 48, 65, 88, 60, 42, 75, 96, 85, 62, 50, 78, 92, 100, 84, 60, 38, 70, 88, 95, 80, 54, 42, 68, 85, 92, 76, 60, 46,
      64, 82, 45,
    ]
    
    const chapters: PodcastChapter[] = [
      {
        id: 1,
        title: 'Intro & State of Tooling',
        subtitle: 'Why traditional CSS workflows break at scale',
        start: 0,
        end: 495,
        durationFormatted: '08:15',
      },
      {
        id: 2,
        title: 'The Monorepo Pivot',
        subtitle: 'Moving away from monolithic npm component packages',
        start: 495,
        end: 1360,
        durationFormatted: '14:25',
      },
      {
        id: 3,
        title: 'OKLCH Math & Contrast Engines',
        subtitle: 'Perceptual uniformity, APCA contrast & wide-gamut P3',
        start: 1360,
        end: 2290,
        durationFormatted: '15:30',
      },
      {
        id: 4,
        title: 'Q&A & Future Standards',
        subtitle: 'Audience questions, migration playbooks & CSS Color 5',
        start: 2290,
        end: 2912,
        durationFormatted: '10:22',
      },
    ]
    
    const panelists: PodcastPanelist[] = [
      {
        name: 'Elena Rostova',
        role: 'Staff Design Engineer',
        company: 'Linear',
        avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&auto=format&fit=crop&q=80',
        fallback: 'ER',
        bio: 'Pioneered Linear dark mode tokens, high-density keyboard workflows, and micro-interactions.',
        handle: '@erostova',
      },
      {
        name: 'Marcus Vance',
        role: 'Head of UI Architecture',
        company: 'Vercel',
        avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&auto=format&fit=crop&q=80',
        fallback: 'MV',
        bio: 'Core architect on headless design systems, token compilers, and zero-runtime CSS workflows.',
        handle: '@marcusvance',
      },
      {
        name: 'Dr. Aris Thorne',
        role: 'Color Science Lead',
        company: 'W3C CSS Working Group',
        avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=150&auto=format&fit=crop&q=80',
        fallback: 'AT',
        bio: 'Author on CSS Color Module 4 & 5, APCA contrast metric integration, and gamut mapping algorithms.',
        handle: '@aristhorne',
      },
    ]
    
    const referenceLinks: PodcastReferenceLink[] = [
      {
        title: 'OKLCH Color Space Visualizer',
        description: 'Interactive gamut mapper, lightness ramp generator, and P3 inspector.',
        url: 'https://oklch.com',
        tag: 'Tooling',
      },
      {
        title: 'W3C CSS Color Module Level 4',
        description: 'Official W3C specification defining oklch(), oklab(), and wide-gamut coordinates.',
        url: 'https://www.w3.org/TR/css-color-4/',
        tag: 'Specification',
      },
      {
        title: 'UIPKGE Component Registry Architecture',
        description: 'The unbundled registry distribution model for Vue and React design systems.',
        url: 'https://uipkge.dev',
        tag: 'Architecture',
      },
      {
        title: 'APCA Accessible Perceptual Contrast Algorithm',
        description: 'Next-generation readability standard replacing legacy WCAG 2 ratio formulas.',
        url: 'https://git.apcacontrast.com',
        tag: 'Accessibility',
      },
    ]
    
    const speedOptions = [1.0, 1.25, 1.5, 2.0]
    
    export function formatPodcastTime(seconds: number): string {
      const m = Math.floor(seconds / 60)
      const s = Math.floor(seconds % 60)
      return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-podcast-audio-player, [ui-podcast-audio-player]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiAccordionComponent,
        UiAccordionContentComponent,
        UiAccordionItemComponent,
        UiAccordionTriggerComponent,
        UiAvatarComponent,
        UiAvatarFallbackComponent,
        UiAvatarImageComponent,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardFooterComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiProgressComponent,
        UiSeparatorComponent,
        UiSliderComponent,
      ],
      template: `
        <div data-slot="podcast-audio-player" [class]="rootClass">
          <!-- EPISODE HERO CARD -->
          <div ui-card class="overflow-hidden border shadow-xs">
            <div ui-card-content class="p-5 sm:p-7">
              <div class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
                <div class="flex flex-col gap-5 sm:flex-row sm:items-center">
                  <div
                    class="from-primary/20 via-primary/10 to-background ring-border/80 group relative flex size-28 shrink-0 items-center justify-center overflow-hidden rounded-2xl border bg-gradient-to-br shadow-md ring-1 sm:size-32"
                  >
                    <div
                      class="border-primary/30 absolute inset-2 rounded-xl border border-dashed opacity-60 transition-transform duration-700 group-hover:rotate-45"
                    ></div>
                    <div
                      class="bg-card/90 relative z-10 flex size-14 items-center justify-center rounded-xl border shadow-xs"
                    >
                      <lucide-icon [img]="Radio" class="text-primary size-7" />
                    </div>
                    <div
                      class="bg-background/90 text-foreground absolute right-2 bottom-2 rounded-md px-1.5 py-0.5 font-mono text-xs font-semibold shadow-xs"
                    >
                      EP #48
                    </div>
                  </div>
    
                  <div class="space-y-2">
                    <div class="flex flex-wrap items-center gap-2">
                      <span ui-badge variant="outline" class="gap-1 text-xs font-semibold">
                        <lucide-icon [img]="Mic" class="text-primary size-3" />
                        <span>The Design Systems Podcast</span>
                      </span>
                      <span ui-badge variant="secondary" class="font-mono text-xs"> Season 4 · Episode #48 </span>
                      <span
                        ui-badge
                        variant="outline"
                        class="border-success/30 bg-success/10 text-success font-mono text-xs"
                      >
                        Lossless 320kbps
                      </span>
                    </div>
    
                    <h1 class="text-foreground text-xl font-bold tracking-tight sm:text-2xl lg:text-3xl">
                      Deconstructing OKLCH & Zero-Dependency Component Registries
                    </h1>
    
                    <div class="text-muted-foreground flex flex-wrap items-center gap-3 text-xs sm:text-sm">
                      <div class="flex items-center gap-2">
                        <div class="flex shrink-0 -space-x-2 overflow-hidden">
                          @for (person of panelists; track person.name) {
                            <ui-avatar class="border-background ring-border size-6 border-2 ring-1">
                              <ui-avatar-image [src]="person.avatar" [alt]="person.name" />
                              <ui-avatar-fallback>{{ person.fallback }}</ui-avatar-fallback>
                            </ui-avatar>
                          }
                        </div>
                        <span class="text-foreground font-medium">Elena Rostova, Marcus Vance & Dr. Aris Thorne</span>
                      </div>
                      <span>·</span>
                      <div class="flex items-center gap-1 font-mono text-xs tabular-nums">
                        <lucide-icon [img]="Clock" class="size-3.5" />
                        <span>48 mins</span>
                      </div>
                    </div>
                  </div>
                </div>
    
                <div class="flex flex-wrap items-center gap-2 border-t pt-4 lg:border-t-0 lg:pt-0">
                  <button
                    ui-button
                    [variant]="isLiked() ? 'default' : 'outline'"
                    size="sm"
                    class="h-9 gap-1.5 text-xs font-semibold shadow-xs"
                    (click)="toggleLike()"
                  >
                    <lucide-icon
                      [img]="Heart"
                      [class]="isLiked() ? 'size-4 fill-current' : 'text-muted-foreground size-4'"
                    />
                    <span class="font-mono tabular-nums">{{ likeCount() }}</span>
                  </button>
    
                  <button
                    ui-button
                    variant="outline"
                    size="icon"
                    class="size-9 shadow-xs"
                    [attr.aria-label]="isBookmarked() ? 'Remove bookmark' : 'Bookmark episode'"
                    (click)="isBookmarked.set(!isBookmarked())"
                  >
                    @if (isBookmarked()) {
                      <lucide-icon [img]="BookmarkCheck" class="text-primary size-4" />
                    } @else {
                      <lucide-icon [img]="Bookmark" class="text-muted-foreground size-4" />
                    }
                  </button>
    
                  <button
                    ui-button
                    variant="outline"
                    size="sm"
                    class="h-9 gap-1.5 text-xs font-semibold shadow-xs"
                    (click)="handleCopyLink()"
                  >
                    @if (isCopied()) {
                      <lucide-icon [img]="Check" class="text-success size-4" />
                    } @else {
                      <lucide-icon [img]="Share2" class="size-4" />
                    }
                    <span>{{ isCopied() ? 'Link Copied' : 'Share' }}</span>
                  </button>
    
                  <button
                    ui-button
                    variant="outline"
                    size="icon"
                    class="size-9 shadow-xs"
                    [attr.aria-label]="isDownloaded() ? 'Downloaded' : 'Download episode'"
                    (click)="handleDownload()"
                  >
                    @if (isDownloaded()) {
                      <lucide-icon [img]="Check" class="text-success size-4" />
                    } @else {
                      <lucide-icon [img]="Download" class="size-4" />
                    }
                  </button>
                </div>
              </div>
            </div>
          </div>
    
          <!-- AUDIO PLAYER CONTROLS (CENTERPIECE) -->
          <div ui-card class="border shadow-xs">
            <div ui-card-content class="space-y-6 p-5 sm:p-7">
              <div class="flex flex-col justify-between gap-2 sm:flex-row sm:items-center">
                <div class="space-y-1">
                  <div class="flex items-center gap-2">
                    <span class="bg-primary size-2 animate-pulse rounded-full"></span>
                    <span class="text-muted-foreground font-mono text-xs font-semibold tracking-wider uppercase">
                      Now Playing · Chapter {{ currentChapter().id }} of {{ chapters.length }}
                    </span>
                  </div>
                  <h2 class="text-foreground text-base font-bold tracking-tight sm:text-lg">
                    {{ currentChapter().title }}
                  </h2>
                  <p class="text-muted-foreground text-xs">
                    {{ currentChapter().subtitle }}
                  </p>
                </div>
    
                <div class="flex items-center gap-2 self-start font-mono text-xs font-semibold tabular-nums sm:self-auto">
                  <span ui-badge variant="outline" class="border-primary/30 bg-primary/5 gap-1 px-2.5 py-1">
                    <span class="text-primary font-bold">{{ formattedCurrentTime() }}</span>
                    <span class="text-muted-foreground">/</span>
                    <span class="text-muted-foreground">{{ formattedTotalTime() }}</span>
                  </span>
                  <span class="text-muted-foreground text-xs">{{ remainingTime() }}</span>
                </div>
              </div>
    
              <!-- 32-BAR VISUAL WAVEFORM SCRUBBER -->
              <div class="space-y-2">
                <div
                  class="group/wave bg-muted/30 hover:bg-muted/50 border-border/80 relative flex h-24 w-full cursor-pointer items-center justify-between rounded-xl border p-4 transition-colors select-none"
                  role="slider"
                  aria-label="Audio Waveform Scrubber"
                  [attr.aria-valuenow]="currentTime()"
                  [attr.aria-valuemin]="0"
                  [attr.aria-valuemax]="totalDuration()"
                  (click)="handleWaveformClick($event)"
                >
                  <div class="flex h-full w-full items-center justify-between gap-1 sm:gap-1.5">
                    @for (barHeight of waveformBars; track $index) {
                      <div class="flex h-full flex-1 items-center justify-center">
                        <div [class]="waveformBarClass($index)" [style.height]="barHeight + '%'"></div>
                      </div>
                    }
                  </div>
    
                  <div
                    class="bg-foreground pointer-events-none absolute top-0 bottom-0 z-10 w-0.5 transition-colors"
                    [style.left]="progressPercent() + '%'"
                  >
                    <div
                      class="bg-primary ring-background absolute -top-1 left-1/2 size-3.5 -translate-x-1/2 rounded-full shadow-md ring-2 transition-transform group-hover/wave:scale-125"
                    ></div>
                  </div>
    
                  <div class="pointer-events-none absolute right-4 bottom-1.5 left-4 flex justify-between">
                    @for (ch of chapters; track ch.id) {
                      <span
                        class="absolute flex flex-col items-center"
                        [style.left]="(ch.start / totalDuration()) * 100 + '%'"
                      >
                        <span class="bg-muted-foreground/50 h-2 w-0.5"></span>
                      </span>
                    }
                  </div>
                </div>
    
                <div class="text-muted-foreground flex items-center justify-between font-mono text-xs tabular-nums">
                  <div class="flex items-center gap-1.5">
                    <span class="text-foreground font-semibold">{{ formattedCurrentTime() }}</span>
                    <span>(Chapter progress: {{ currentChapterProgressRounded() }}%)</span>
                  </div>
                  <span>{{ formattedTotalTime() }}</span>
                </div>
              </div>
    
              <!-- MAIN PLAYBACK CONTROLS BAR -->
              <div class="flex flex-col items-center justify-between gap-4 pt-2 md:flex-row">
                <div class="flex items-center gap-2">
                  <button
                    ui-button
                    variant="outline"
                    size="sm"
                    class="hover:bg-accent h-9 gap-1 px-2.5 font-mono text-xs font-bold shadow-xs"
                    aria-label="Cycle Playback Speed"
                    (click)="cycleSpeed()"
                  >
                    <lucide-icon [img]="FastForward" class="text-primary size-3.5" />
                    <span>{{ speedLabel() }}</span>
                  </button>
    
                  <button
                    ui-button
                    variant="ghost"
                    size="icon"
                    class="text-muted-foreground hover:text-foreground size-9"
                    aria-label="Previous Chapter"
                    (click)="skipToPrevChapter()"
                  >
                    <lucide-icon [img]="SkipBack" class="size-4" />
                  </button>
    
                  <button
                    ui-button
                    variant="ghost"
                    size="icon"
                    class="text-muted-foreground hover:text-foreground size-9"
                    aria-label="Next Chapter"
                    (click)="skipToNextChapter()"
                  >
                    <lucide-icon [img]="SkipForward" class="size-4" />
                  </button>
                </div>
    
                <div class="flex items-center gap-3">
                  <button
                    ui-button
                    variant="outline"
                    size="icon"
                    class="size-10 rounded-full shadow-xs"
                    aria-label="Rewind 15 seconds"
                    (click)="skipSeconds(-15)"
                  >
                    <lucide-icon [img]="RotateCcw" class="size-4.5" />
                  </button>
    
                  <button
                    ui-button
                    variant="default"
                    size="icon"
                    class="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring size-13 rounded-full shadow-lg transition-[color,background-color,border-color,box-shadow,opacity,transform,scale,translate,rotate] hover:scale-105 focus-visible:ring-2 active:scale-95"
                    [attr.aria-label]="isPlaying() ? 'Pause episode' : 'Play episode'"
                    (click)="togglePlay()"
                  >
                    @if (isPlaying()) {
                      <lucide-icon [img]="Pause" class="size-6 fill-current" />
                    } @else {
                      <lucide-icon [img]="Play" class="ml-0.5 size-6 fill-current" />
                    }
                  </button>
    
                  <button
                    ui-button
                    variant="outline"
                    size="icon"
                    class="size-10 rounded-full shadow-xs"
                    aria-label="Fast forward 15 seconds"
                    (click)="skipSeconds(15)"
                  >
                    <lucide-icon [img]="RotateCw" class="size-4.5" />
                  </button>
                </div>
    
                <div class="flex items-center gap-2.5">
                  <button
                    ui-button
                    variant="ghost"
                    size="icon"
                    class="text-muted-foreground hover:text-foreground size-9"
                    [attr.aria-label]="isMuted() ? 'Unmute' : 'Mute'"
                    (click)="toggleMute()"
                  >
                    @if (isMuted() || volume() === 0) {
                      <lucide-icon [img]="VolumeX" class="text-destructive size-4.5" />
                    } @else if (volume() < 50) {
                      <lucide-icon [img]="Volume1" class="size-4.5" />
                    } @else {
                      <lucide-icon [img]="Volume2" class="size-4.5" />
                    }
                  </button>
    
                  <div class="w-24 sm:w-28">
                    <ui-slider
                      [value]="[volume()]"
                      [min]="0"
                      [max]="100"
                      [step]="1"
                      [disabled]="isMuted()"
                      class="cursor-pointer"
                      (valueChange)="volume.set($event[0] ?? volume())"
                    />
                  </div>
                  <span class="text-muted-foreground w-8 text-right font-mono text-xs tabular-nums">
                    {{ isMuted() ? '0%' : volume() + '%' }}
                  </span>
                </div>
              </div>
    
              <ui-separator />
    
              <!-- CHAPTER NAVIGATION BAR -->
              <div class="space-y-3">
                <div class="flex items-center justify-between">
                  <div class="flex items-center gap-2">
                    <lucide-icon [img]="ListMusic" class="text-primary size-4" />
                    <h3 class="text-foreground text-xs font-bold tracking-tight uppercase">Chapter Navigation</h3>
                  </div>
                  <span class="text-muted-foreground font-mono text-xs"> 4 Chapters · 48m 32s Total </span>
                </div>
    
                <div class="grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-4">
                  @for (ch of chapters; track ch.id) {
                    <div [class]="chapterCardClass(ch)" (click)="seekTo(ch.start)">
                      <div class="space-y-1">
                        <div class="flex items-center justify-between">
                          <span
                            ui-badge
                            [variant]="currentChapter().id === ch.id ? 'default' : 'outline'"
                            class="h-5 px-1.5 font-mono text-xs font-semibold"
                          >
                            CH {{ ch.id }}
                          </span>
                          <span class="text-muted-foreground font-mono text-xs tabular-nums">
                            {{ formatTime(ch.start) }}
                          </span>
                        </div>
                        <div class="text-foreground line-clamp-1 font-semibold">
                          {{ ch.title }}
                        </div>
                        <div class="text-muted-foreground line-clamp-1 text-xs">
                          {{ ch.subtitle }}
                        </div>
                      </div>
    
                      <div class="mt-2.5 pt-1">
                        @if (currentChapter().id === ch.id) {
                          <ui-progress [value]="currentChapterProgress()" class="h-1" />
                        } @else {
                          <div
                            [class]="
                              currentTime() >= ch.end
                                ? 'bg-primary/40 h-1 w-full rounded-full'
                                : 'bg-muted h-1 w-full rounded-full'
                            "
                          ></div>
                        }
                      </div>
                    </div>
                  }
                </div>
              </div>
            </div>
          </div>
    
          <!-- EPISODE SHOW NOTES & GUEST LINKS ACCORDION -->
          <div ui-card class="border shadow-xs">
            <div ui-card-header class="pb-3">
              <div class="flex items-center justify-between">
                <div class="flex items-center gap-2">
                  <lucide-icon [img]="BookOpen" class="text-primary size-4" />
                  <h3 ui-card-title class="text-base font-bold tracking-tight sm:text-lg">
                    Episode Show Notes & Comprehensive Guide
                  </h3>
                </div>
                <span ui-badge variant="outline" class="font-mono text-xs"> Full Notes </span>
              </div>
              <p ui-card-description class="text-xs sm:text-sm">
                Deep dive references, color math formulas, guest bios, and resource bookmarks from this episode.
              </p>
            </div>
    
            <div ui-card-content class="pt-1">
              <ui-accordion type="single" collapsible defaultValue="summary" class="w-full">
                <ui-accordion-item value="summary">
                  <button ui-accordion-trigger class="text-xs font-semibold hover:no-underline sm:text-sm">
                    <span class="flex items-center gap-2">
                      <span
                        class="bg-primary/10 text-primary flex size-5 items-center justify-center rounded-full text-xs font-bold"
                        >1</span
                      >
                      <span>Executive Summary & Key Takeaways</span>
                    </span>
                  </button>
                  <ui-accordion-content class="space-y-3 pt-2 text-xs leading-relaxed">
                    <p class="text-muted-foreground">
                      In this episode, we break down why traditional HSL and sRGB color models fall short when building
                      modern, accessible multi-theme design systems. We explore how the OKLCH color space decouples
                      perceptual lightness from chroma and hue, eliminating contrast inversion bugs when shifting from light
                      to OLED dark mode.
                    </p>
    
                    <div class="grid grid-cols-1 gap-3 pt-1 sm:grid-cols-3">
                      <div class="bg-muted/30 space-y-1 rounded-lg border p-3">
                        <div class="text-foreground font-semibold">Perceptual Uniformity</div>
                        <p class="text-muted-foreground text-xs">
                          Lightness (L) in OKLCH remains consistent regardless of hue angle, unlike HSL where yellow appears
                          far brighter than blue.
                        </p>
                      </div>
    
                      <div class="bg-muted/30 space-y-1 rounded-lg border p-3">
                        <div class="text-foreground font-semibold">Zero-Dependency Registries</div>
                        <p class="text-muted-foreground text-xs">
                          Unbundled component distribution gives engineering teams 100% source code ownership with zero
                          semantic version drift.
                        </p>
                      </div>
    
                      <div class="bg-muted/30 space-y-1 rounded-lg border p-3">
                        <div class="text-foreground font-semibold">Tailwind v4 &#64;theme</div>
                        <p class="text-muted-foreground text-xs">
                          Direct CSS variable binding with OKLCH tokens unlocks dynamic color-mix tints without JavaScript
                          runtime overhead.
                        </p>
                      </div>
                    </div>
                  </ui-accordion-content>
                </ui-accordion-item>
    
                <ui-accordion-item value="chapters">
                  <button ui-accordion-trigger class="text-xs font-semibold hover:no-underline sm:text-sm">
                    <span class="flex items-center gap-2">
                      <span
                        class="bg-primary/10 text-primary flex size-5 items-center justify-center rounded-full text-xs font-bold"
                        >2</span
                      >
                      <span>Timestamped Chapter Breakdown & Discussion Topics</span>
                    </span>
                  </button>
                  <ui-accordion-content class="space-y-2.5 pt-2 text-xs">
                    @for (ch of chapters; track ch.id) {
                      <div
                        class="bg-muted/20 hover:bg-muted/40 flex items-center justify-between rounded-lg border p-3 transition-colors"
                      >
                        <div class="space-y-0.5">
                          <div class="flex items-center gap-2">
                            <span class="text-foreground font-semibold">Chapter {{ ch.id }}: {{ ch.title }}</span>
                          </div>
                          <p class="text-muted-foreground text-xs">{{ ch.subtitle }}</p>
                        </div>
    
                        <button
                          ui-button
                          variant="outline"
                          size="sm"
                          class="h-7 gap-1 px-2.5 font-mono text-xs font-bold tabular-nums"
                          (click)="seekTo(ch.start)"
                        >
                          <lucide-icon [img]="Play" class="text-primary size-2.5 fill-current" />
                          <span>{{ formatTime(ch.start) }}</span>
                        </button>
                      </div>
                    }
                  </ui-accordion-content>
                </ui-accordion-item>
    
                <ui-accordion-item value="panelists">
                  <button ui-accordion-trigger class="text-xs font-semibold hover:no-underline sm:text-sm">
                    <span class="flex items-center gap-2">
                      <span
                        class="bg-primary/10 text-primary flex size-5 items-center justify-center rounded-full text-xs font-bold"
                        >3</span
                      >
                      <span>Featured Hosts & Guest Panel</span>
                    </span>
                  </button>
                  <ui-accordion-content class="space-y-3 pt-2 text-xs">
                    <div class="grid grid-cols-1 gap-3 md:grid-cols-3">
                      @for (person of panelists; track person.name) {
                        <div class="bg-muted/25 flex flex-col justify-between space-y-3 rounded-xl border p-3.5">
                          <div class="flex items-start gap-3">
                            <ui-avatar class="ring-border size-10 border ring-1">
                              <ui-avatar-image [src]="person.avatar" [alt]="person.name" />
                              <ui-avatar-fallback>{{ person.fallback }}</ui-avatar-fallback>
                            </ui-avatar>
                            <div class="min-w-0 space-y-0.5">
                              <div class="text-foreground truncate font-bold">{{ person.name }}</div>
                              <div class="text-muted-foreground truncate text-xs">{{ person.role }}</div>
                              <span ui-badge variant="secondary" class="h-4 px-1 text-xs">
                                {{ person.company }}
                              </span>
                            </div>
                          </div>
                          <p class="text-muted-foreground text-xs leading-relaxed">
                            {{ person.bio }}
                          </p>
                          <div class="text-primary font-mono text-xs">
                            {{ person.handle }}
                          </div>
                        </div>
                      }
                    </div>
                  </ui-accordion-content>
                </ui-accordion-item>
    
                <ui-accordion-item value="resources">
                  <button ui-accordion-trigger class="text-xs font-semibold hover:no-underline sm:text-sm">
                    <span class="flex items-center gap-2">
                      <span
                        class="bg-primary/10 text-primary flex size-5 items-center justify-center rounded-full text-xs font-bold"
                        >4</span
                      >
                      <span>Mentioned Resources & Specifications</span>
                    </span>
                  </button>
                  <ui-accordion-content class="space-y-2.5 pt-2 text-xs">
                    <div class="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
                      @for (link of referenceLinks; track link.title) {
                        <a
                          [href]="link.url"
                          target="_blank"
                          rel="noreferrer"
                          class="bg-muted/25 hover:bg-muted/50 group flex flex-col justify-between rounded-xl border p-3.5 transition-colors"
                        >
                          <div class="space-y-1">
                            <div class="flex items-center justify-between">
                              <span
                                class="text-foreground group-hover:text-primary flex items-center gap-1.5 font-semibold transition-colors"
                              >
                                {{ link.title }}
                                <lucide-icon
                                  [img]="ExternalLink"
                                  class="text-muted-foreground group-hover:text-primary size-3 transition-colors"
                                />
                              </span>
                              <span ui-badge variant="outline" class="font-mono text-xs">
                                {{ link.tag }}
                              </span>
                            </div>
                            <p class="text-muted-foreground text-xs leading-relaxed">
                              {{ link.description }}
                            </p>
                          </div>
                          <div class="text-muted-foreground pt-2 font-mono text-xs">
                            {{ link.url }}
                          </div>
                        </a>
                      }
                    </div>
                  </ui-accordion-content>
                </ui-accordion-item>
              </ui-accordion>
            </div>
    
            <div ui-card-footer class="bg-muted/20 flex flex-wrap items-center justify-between gap-3 border-t p-4 text-xs">
              <div class="text-muted-foreground flex items-center gap-2">
                <lucide-icon [img]="Headphones" class="text-primary size-4" />
                <span>Produced by The Design Systems Guild · All rights reserved</span>
              </div>
    
              <div class="flex items-center gap-2">
                <button ui-button variant="ghost" size="sm" class="h-7 text-xs" (click)="handleCopyLink()">
                  <lucide-icon [img]="Copy" class="mr-1 size-3.5" />
                  <span>Copy Transcript Link</span>
                </button>
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiPodcastAudioPlayerComponent {
      protected readonly BookOpen = BookOpen
      protected readonly Bookmark = Bookmark
      protected readonly BookmarkCheck = BookmarkCheck
      protected readonly Check = Check
      protected readonly Clock = Clock
      protected readonly Copy = Copy
      protected readonly Download = Download
      protected readonly ExternalLink = ExternalLink
      protected readonly FastForward = FastForward
      protected readonly Headphones = Headphones
      protected readonly Heart = Heart
      protected readonly ListMusic = ListMusic
      protected readonly Mic = Mic
      protected readonly Pause = Pause
      protected readonly Play = Play
      protected readonly Radio = Radio
      protected readonly RotateCcw = RotateCcw
      protected readonly RotateCw = RotateCw
      protected readonly Share2 = Share2
      protected readonly SkipBack = SkipBack
      protected readonly SkipForward = SkipForward
      protected readonly Volume1 = Volume1
      protected readonly Volume2 = Volume2
      protected readonly VolumeX = VolumeX
    
      protected readonly waveformBars = waveformBars
      protected readonly chapters = chapters
      protected readonly panelists = panelists
      protected readonly referenceLinks = referenceLinks
    
      @Input('class') className?: string
      @Input() set initialTime(v: number | undefined) {
        if (v !== undefined) this.currentTime.set(v)
      }
      @Input() set initialPlaying(v: boolean) {
        if (v) this.startPlayback()
        else this.stopPlayback()
      }
    
      readonly isPlaying = signal(false)
      readonly currentTime = signal(860)
      readonly totalDuration = signal(2912)
      readonly volume = signal(80)
      readonly isMuted = signal(false)
      readonly playbackSpeed = signal(1.0)
      readonly isBookmarked = signal(false)
      readonly isLiked = signal(false)
      readonly likeCount = signal(342)
      readonly isCopied = signal(false)
      readonly isDownloaded = signal(false)
    
      private playInterval: ReturnType<typeof setInterval> | null = null
      private copyTimer: ReturnType<typeof setTimeout> | undefined
      private downloadTimer: ReturnType<typeof setTimeout> | undefined
    
      constructor() {
        inject(DestroyRef).onDestroy(() => {
          if (this.playInterval) clearInterval(this.playInterval)
          clearTimeout(this.copyTimer)
          clearTimeout(this.downloadTimer)
        })
      }
    
      readonly formattedCurrentTime = computed(() => formatPodcastTime(this.currentTime()))
      readonly formattedTotalTime = computed(() => formatPodcastTime(this.totalDuration()))
      readonly remainingTime = computed(() => `-${formatPodcastTime(this.totalDuration() - this.currentTime())}`)
      readonly progressPercent = computed(() => (this.currentTime() / this.totalDuration()) * 100)
    
      readonly currentChapter = computed<PodcastChapter>(() => {
        const t = this.currentTime()
        return chapters.find((c) => t >= c.start && t < c.end) ?? chapters[chapters.length - 1]
      })
    
      readonly currentChapterProgress = computed(() => {
        const ch = this.currentChapter()
        const elapsed = this.currentTime() - ch.start
        const total = ch.end - ch.start
        return Math.min(100, Math.max(0, (elapsed / total) * 100))
      })
    
      readonly currentChapterProgressRounded = computed(() => Math.round(this.currentChapterProgress()))
    
      readonly speedLabel = computed(() => {
        const s = this.playbackSpeed()
        return `${s.toFixed(s % 1 === 0 ? 1 : 2)}x`
      })
    
      get rootClass(): string {
        return cn('bg-background text-foreground w-full space-y-6', this.className)
      }
    
      waveformBarClass(index: number): string {
        const active = (index / waveformBars.length) * 100 <= this.progressPercent()
        const live = this.isPlaying() && Math.floor((this.progressPercent() / 100) * waveformBars.length) === index
        return cn(
          'w-full max-w-[8px] rounded-full transition-[height] duration-150',
          active ? 'bg-primary' : 'bg-muted-foreground/25 group-hover/wave:bg-muted-foreground/35',
          live && 'ring-primary/40 scale-y-110 ring-2',
        )
      }
    
      chapterCardClass(ch: PodcastChapter): string {
        return cn(
          'group flex cursor-pointer flex-col justify-between rounded-xl border p-3 text-xs transition-colors duration-200',
          this.currentChapter().id === ch.id
            ? 'border-primary/50 bg-primary/5 ring-primary/20 shadow-xs ring-1'
            : 'bg-card hover:bg-muted/40 border-border/70',
        )
      }
    
      formatTime(seconds: number): string {
        return formatPodcastTime(seconds)
      }
    
      togglePlay(): void {
        if (this.isPlaying()) this.stopPlayback()
        else this.startPlayback()
      }
    
      private startPlayback(): void {
        this.isPlaying.set(true)
        if (this.playInterval) clearInterval(this.playInterval)
        this.playInterval = setInterval(() => {
          if (this.currentTime() < this.totalDuration()) {
            this.currentTime.update((t) => t + 1)
          } else {
            this.stopPlayback()
          }
        }, 1000 / this.playbackSpeed())
      }
    
      private stopPlayback(): void {
        this.isPlaying.set(false)
        if (this.playInterval) {
          clearInterval(this.playInterval)
          this.playInterval = null
        }
      }
    
      seekTo(seconds: number): void {
        this.currentTime.set(Math.max(0, Math.min(seconds, this.totalDuration())))
      }
    
      handleWaveformClick(event: MouseEvent): void {
        const target = event.currentTarget as HTMLElement | null
        if (!target) return
        const rect = target.getBoundingClientRect()
        if (rect.width === 0) return
        const ratio = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width))
        this.seekTo(Math.round(ratio * this.totalDuration()))
      }
    
      skipSeconds(delta: number): void {
        this.seekTo(this.currentTime() + delta)
      }
    
      cycleSpeed(): void {
        const idx = speedOptions.indexOf(this.playbackSpeed())
        const next = speedOptions[(idx + 1) % speedOptions.length]
        this.playbackSpeed.set(next)
        if (this.isPlaying()) this.startPlayback()
      }
    
      skipToNextChapter(): void {
        const idx = chapters.findIndex((c) => c.id === this.currentChapter().id)
        if (idx < chapters.length - 1) this.seekTo(chapters[idx + 1].start)
      }
    
      skipToPrevChapter(): void {
        const ch = this.currentChapter()
        if (this.currentTime() - ch.start > 4) {
          this.seekTo(ch.start)
        } else {
          const idx = chapters.findIndex((c) => c.id === ch.id)
          if (idx > 0) this.seekTo(chapters[idx - 1].start)
          else this.seekTo(0)
        }
      }
    
      toggleMute(): void {
        this.isMuted.update((m) => !m)
      }
    
      toggleLike(): void {
        const next = !this.isLiked()
        this.isLiked.set(next)
        this.likeCount.update((c) => c + (next ? 1 : -1))
      }
    
      handleCopyLink(): void {
        try {
          void navigator.clipboard?.writeText(window.location.href)
        } catch {
          // Clipboard unavailable (jsdom) — still show feedback.
        }
        this.isCopied.set(true)
        clearTimeout(this.copyTimer)
        this.copyTimer = setTimeout(() => this.isCopied.set(false), 2000)
      }
    
      handleDownload(): void {
        this.isDownloaded.set(true)
        clearTimeout(this.downloadTimer)
        this.downloadTimer = setTimeout(() => this.isDownloaded.set(false), 3000)
      }
    }
    
  • src/app/components/blocks/podcast-audio-player/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/podcast-audio-player.json