Podcast Audio Player
blockmediaSpotify and Apple Podcasts style audio player with 32-bar visual SVG waveform scrubber, chapter markers, playback speed controls, and show notes accordion.
Installation
$pnpm dlx uipkge-ng@latest add podcast-audio-player$npx uipkge-ng@latest add podcast-audio-player$yarn dlx uipkge-ng@latest add podcast-audio-player$bunx uipkge-ng@latest add podcast-audio-playerManifest:
https://uipkge.dev/r/angular/podcast-audio-player.jsonInstalls to:src/app/components/blocks/podcast-audio-player/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
PodcastChapterinterface PodcastChapter {
id: number
title: string
subtitle: string
start: number
end: number
durationFormatted: string
}PodcastPanelistinterface PodcastPanelist {
name: string
role: string
company: string
avatar: string
fallback: string
bio: string
handle: string
}PodcastReferenceLinkinterface 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 @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
export { UiPodcastAudioPlayerComponent, formatPodcastTime, type PodcastChapter, type PodcastPanelist, type PodcastReferenceLink, } from './podcast-audio-player.component'
Raw manifest:https://uipkge.dev/r/angular/podcast-audio-player.json