Menu

Change language

GitHub repo

Lesson Video Player

blockeducation

Online lecture video player with playback speed selector, interactive timestamped transcript, and note-taking drawer.

Installation

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

Variants

Loading interactive previews…

Schema

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

TranscriptItem
interface TranscriptItem {
  id: string
  start: number
  end: number
  speaker: string
  text: string
}
NoteItem
interface NoteItem {
  id: string
  timestamp: number
  text: string
  createdAt: string
}
QuestionItem
interface QuestionItem {
  id: string
  timestamp: number
  author: string
  avatar: string
  question: string
  upvotes: number
  hasUpvoted?: boolean
  answer?: { author: string; role: string; text: string; isInstructor?: boolean }
}

Files installed (2)

  • src/app/components/blocks/lesson-video-player/lesson-video-player.component.ts39.1 kB
    // Online lecture video player with playback speed selector, interactive timestamped
    // transcript, and note-taking drawer. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, OnDestroy, computed, signal } from '@angular/core'
    import {
      ArrowRight,
      Bookmark,
      BookmarkCheck,
      Check,
      CheckCircle2,
      ChevronRight,
      Clock,
      Download,
      FileText,
      Layers,
      Lightbulb,
      LucideAngularModule,
      Maximize2,
      MessageSquare,
      Pause,
      Play,
      Plus,
      RotateCcw,
      RotateCw,
      Search,
      Subtitles,
      ThumbsUp,
      Trash2,
      Volume2,
      VolumeX,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import { UiProgressComponent } from '@/ui/progress/progress.component'
    import {
      UiTabsComponent,
      UiTabsContentComponent,
      UiTabsListComponent,
      UiTabsTriggerComponent,
    } from '@/ui/tabs/tabs.component'
    import { UiTextareaComponent } from '@/ui/textarea/textarea.component'
    
    export interface TranscriptItem {
      id: string
      start: number
      end: number
      speaker: string
      text: string
    }
    
    export interface NoteItem {
      id: string
      timestamp: number
      text: string
      createdAt: string
    }
    
    export interface QuestionItem {
      id: string
      timestamp: number
      author: string
      avatar: string
      question: string
      upvotes: number
      hasUpvoted?: boolean
      answer?: { author: string; role: string; text: string; isInstructor?: boolean }
    }
    
    export const transcriptSeed: TranscriptItem[] = [
      {
        id: 't-1',
        start: 0,
        end: 75,
        speaker: 'Sarah Connor',
        text: 'Welcome back! In this lesson, we are tackling one of the trickiest parts of design systems: building resilient dark-mode token palettes using the OKLCH color space.',
      },
      {
        id: 't-2',
        start: 75,
        end: 220,
        speaker: 'Sarah Connor',
        text: 'Why do traditional HSL and sRGB color models fall short? Because perceptual brightness is non-uniform — pure blue at 50% lightness looks vastly darker to the human eye than pure yellow at 50% lightness.',
      },
      {
        id: 't-3',
        start: 220,
        end: 384,
        speaker: 'Sarah Connor',
        text: 'OKLCH solves this by decoupling perceived lightness (L) from chroma (C) and hue (H). When you step lightness down by 10%, the perceived luminance decreases identically regardless of hue angle.',
      },
      {
        id: 't-4',
        start: 384,
        end: 555,
        speaker: 'Sarah Connor',
        text: 'When calculating contrast in OKLCH, lightness is perceptually uniform across hues, which prevents dark-mode contrast crushing. Notice our formula on the slide.',
      },
      {
        id: 't-5',
        start: 555,
        end: 750,
        speaker: 'Sarah Connor',
        text: 'Next, let us discuss wide-gamut Display P3 displays. While P3 provides 25% richer saturation, we must ensure automatic fallback clamping for legacy sRGB monitors.',
      },
      {
        id: 't-6',
        start: 750,
        end: 945,
        speaker: 'Sarah Connor',
        text: 'In dark mode, human retinas are more sensitive to intense chroma saturation on dark surfaces. A solid heuristic is to reduce chroma by 15% to 20% on OLED dark backgrounds.',
      },
      {
        id: 't-7',
        start: 945,
        end: 1120,
        speaker: 'Sarah Connor',
        text: 'To wrap up, download the starter configuration below. In the next lesson, we will wire these token variables directly into our Tailwind CSS v4 @theme inline directives.',
      },
    ]
    
    export const notesSeed: NoteItem[] = [
      {
        id: 'n-1',
        timestamp: 102,
        text: 'Pure blue vs pure yellow in HSL: huge luminance mismatch. OKLCH fixes this at the root.',
        createdAt: '2 hours ago',
      },
      {
        id: 'n-2',
        timestamp: 252,
        text: 'Review color-mix in oklab formulas — can generate surface elevation tints with a single token!',
        createdAt: '1 hour ago',
      },
      {
        id: 'n-3',
        timestamp: 384,
        text: 'Lightness rule: Keep L >= 0.70 for primary brand text on dark surfaces to guarantee APCA Lc 60 minimum.',
        createdAt: 'Just now',
      },
    ]
    
    export const questionsSeed: QuestionItem[] = [
      {
        id: 'q-1',
        timestamp: 225,
        author: 'David K.',
        avatar: 'DK',
        question: 'Is browser support for OKLCH color-mix and raw oklch() ready for enterprise SaaS applications?',
        upvotes: 18,
        hasUpvoted: false,
        answer: {
          author: 'Sarah Connor',
          role: 'Instructor',
          isInstructor: true,
          text: 'Yes! OKLCH has 98%+ global browser support across all evergreen browsers. Tailwind CSS v4 uses it as the default token format natively.',
        },
      },
      {
        id: 'q-2',
        timestamp: 380,
        author: 'Elena R.',
        avatar: 'ER',
        question: 'How do you prevent high chroma vibration on dark OLED zinc-950 surfaces?',
        upvotes: 12,
        hasUpvoted: false,
        answer: {
          author: 'Sarah Connor',
          role: 'Instructor',
          isInstructor: true,
          text: 'Scale down chroma by multiplying C * 0.82 on dark tokens, while boosting lightness by +0.08 to preserve legibility without glare.',
        },
      },
    ]
    
    export function formatTime(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-lesson-video-player, [ui-lesson-video-player]',
      standalone: true,
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiInputComponent,
        UiProgressComponent,
        UiTabsComponent,
        UiTabsContentComponent,
        UiTabsListComponent,
        UiTabsTriggerComponent,
        UiTextareaComponent,
      ],
      template: `
        <div data-slot="lesson-video-player" [class]="rootClass">
          <!-- Top Lesson Navigation & Course Header Bar -->
          <header class="bg-card rounded-xl border p-4 shadow-xs sm:px-6 sm:py-4">
            <div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
              <div class="space-y-1.5">
                <div class="flex flex-wrap items-center gap-2 text-xs">
                  <span class="text-muted-foreground font-medium">Design Systems with Tailwind CSS v4</span>
                  <lucide-icon [img]="ChevronRight" class="text-muted-foreground size-3.5" />
                  <span ui-badge variant="outline" class="gap-1 text-xs font-semibold">
                    <lucide-icon [img]="Layers" class="text-primary size-3" />
                    <span>Module 1: Design Tokens Architecture</span>
                  </span>
                  <span class="text-muted-foreground hidden font-medium sm:inline">Lesson 4 of 12</span>
                </div>
                <h1 class="text-foreground text-lg font-bold tracking-tight sm:text-xl">
                  Lesson 4: Building Resilient OKLCH Dark-Mode Token Palettes
                </h1>
              </div>
    
              <div class="flex flex-wrap items-center gap-2.5">
                <div class="bg-muted/40 hidden items-center gap-2 rounded-lg border px-3 py-1.5 sm:flex">
                  <div class="space-y-1 text-right">
                    <div class="text-muted-foreground text-xs font-medium">Module Progress</div>
                    <div class="font-mono text-xs font-bold tabular-nums">4/12 (33%)</div>
                  </div>
                  <div class="w-12">
                    <ui-progress [value]="33" class="h-1.5" />
                  </div>
                </div>
    
                <button
                  ui-button
                  variant="outline"
                  size="icon"
                  class="size-8.5"
                  [attr.aria-label]="isBookmarked() ? 'Remove bookmark' : 'Bookmark lesson'"
                  (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]="isCompleted() ? 'default' : 'outline'"
                  size="sm"
                  class="h-8.5 gap-1.5 text-xs font-semibold shadow-xs"
                  (click)="isCompleted.set(!isCompleted())"
                >
                  @if (isCompleted()) {
                    <lucide-icon [img]="CheckCircle2" class="size-3.5" />
                  } @else {
                    <lucide-icon [img]="Check" class="size-3.5" />
                  }
                  <span>{{ isCompleted() ? 'Completed' : 'Mark Complete' }}</span>
                </button>
    
                <button ui-button variant="default" size="sm" class="h-8.5 gap-1.5 text-xs font-semibold shadow-xs">
                  <span>Next Lesson</span>
                  <lucide-icon [img]="ArrowRight" class="size-3.5" />
                </button>
              </div>
            </div>
          </header>
    
          <!-- 2-Column Classroom Layout -->
          <div class="grid grid-cols-1 gap-6 lg:grid-cols-12">
            <!-- Left Column: Video Player Canvas + Takeaways & Downloads -->
            <section class="space-y-5 lg:col-span-8">
              <div
                class="group relative flex min-h-[260px] w-full flex-col justify-between overflow-hidden rounded-xl border border-zinc-800 bg-zinc-950 text-white shadow-lg select-none"
              >
                <div class="z-20 flex items-center justify-between p-3.5 sm:p-4">
                  <div class="flex items-center gap-2">
                    <span
                      ui-badge
                      variant="outline"
                      class="border-zinc-700 bg-zinc-900/80 px-2 py-0.5 font-mono text-xs font-medium text-zinc-300 backdrop-blur-md"
                    >
                      1080p 60fps HD
                    </span>
                    <span
                      ui-badge
                      variant="outline"
                      class="border-primary/40 bg-primary/20 text-primary-foreground px-2 py-0.5 text-xs font-semibold backdrop-blur-md"
                    >
                      Lesson 4 / 12
                    </span>
                  </div>
                  <div
                    class="flex items-center gap-2 rounded-full border border-zinc-800 bg-zinc-900/80 px-3 py-1 text-xs text-zinc-300 backdrop-blur-md"
                  >
                    <span class="bg-success size-2 rounded-full"></span>
                    <span class="font-medium">Sarah Connor</span>
                    <span class="text-muted-foreground">·</span>
                    <span class="text-muted-foreground">Principal Design Engineer</span>
                  </div>
                </div>
    
                <div class="relative my-auto flex flex-col items-center justify-center px-6 py-4 text-center">
                  <div
                    class="relative w-full max-w-xl space-y-3 rounded-lg border border-zinc-800/80 bg-zinc-900/85 p-4 text-left shadow-sm backdrop-blur-md"
                  >
                    <div class="flex items-center justify-between border-b border-zinc-800 pb-2">
                      <div class="flex items-center gap-2">
                        <div class="flex gap-1.5">
                          <span class="bg-destructive/80 size-2.5 rounded-full"></span>
                          <span class="bg-warning/80 size-2.5 rounded-full"></span>
                          <span class="bg-success/80 size-2.5 rounded-full"></span>
                        </div>
                        <span class="text-muted-foreground font-mono text-xs"
                          >tokens.config.css — OKLCH Palette Engine</span
                        >
                      </div>
                      <span
                        ui-badge
                        variant="outline"
                        class="border-success/40 bg-success/10 text-success font-mono text-xs"
                      >
                        APCA Lc 74 (AAA)
                      </span>
                    </div>
                    <pre
                      class="overflow-x-auto font-mono text-xs leading-relaxed text-zinc-300"
                    ><code><span class="text-muted-foreground">/* OKLCH Perceptual Uniformity Matrix */</span>
    <span class="text-chart-1">&#64;theme inline</span> &#123;
      <span class="text-info">--color-primary</span>: <span class="text-warning">oklch(0.62 0.19 259.8)</span>; <span class="text-muted-foreground">/* Brand Base */</span>
      <span class="text-info">--color-surface-dark</span>: <span class="text-warning">oklch(0.18 0.02 260.0)</span>; <span class="text-muted-foreground">/* OLED Safe */</span>
    &#125;</code></pre>
                    <div class="flex items-center gap-2 pt-1">
                      <span class="text-muted-foreground text-xs font-medium">Lightness Ramp:</span>
                      <div class="flex flex-1 items-center gap-1.5">
                        <div class="h-5 flex-1 rounded bg-[oklch(0.95_0.04_259.8)] shadow-xs" title="95% L"></div>
                        <div class="h-5 flex-1 rounded bg-[oklch(0.80_0.10_259.8)] shadow-xs" title="80% L"></div>
                        <div
                          class="h-5 flex-1 rounded bg-[oklch(0.62_0.19_259.8)] shadow-xs ring-1 ring-white/60"
                          title="62% L (Base)"
                        ></div>
                        <div class="h-5 flex-1 rounded bg-[oklch(0.40_0.15_259.8)] shadow-xs" title="40% L"></div>
                        <div class="h-5 flex-1 rounded bg-[oklch(0.18_0.02_259.8)] shadow-xs" title="18% L (Dark)"></div>
                      </div>
                    </div>
                  </div>
    
                  <button
                    type="button"
                    class="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring absolute inset-0 m-auto flex size-14 cursor-pointer items-center justify-center rounded-full shadow-lg transition-colors focus-visible:ring-2 focus-visible:outline-none"
                    [attr.aria-label]="isPlaying() ? 'Pause video' : 'Play video'"
                    (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>
                </div>
    
                @if (showCaptions() && activeTranscriptItem()) {
                  <div
                    class="z-20 mx-auto mb-1 max-w-xl rounded-lg border border-white/10 bg-black/80 px-4 py-1.5 text-center text-xs font-medium text-zinc-200 shadow-md backdrop-blur-md sm:text-sm"
                  >
                    <span class="text-muted-foreground">[{{ formatTime(activeTranscriptItem()!.start) }}]</span>
                    {{ activeTranscriptItem()!.text }}
                  </div>
                }
    
                <div
                  class="z-20 space-y-2 bg-gradient-to-t from-black/95 via-black/85 to-transparent p-3 sm:px-4 sm:pt-4 sm:pb-3.5"
                >
                  <div
                    class="group/bar relative flex h-4 w-full cursor-pointer items-center"
                    role="slider"
                    aria-label="Video Timeline Scrubber"
                    [attr.aria-valuenow]="currentTime()"
                    aria-valuemin="0"
                    [attr.aria-valuemax]="totalDuration()"
                    (click)="handleSeekClick($event)"
                  >
                    <div
                      class="relative h-1.5 w-full overflow-hidden rounded-full bg-zinc-800 transition-[height] group-hover/bar:h-2.5"
                    >
                      <div
                        class="absolute top-0 bottom-0 left-0 bg-zinc-700 transition-colors"
                        [style.width.%]="bufferedPercent()"
                      ></div>
                      <div
                        class="bg-primary absolute top-0 bottom-0 left-0 transition-colors"
                        [style.width.%]="progressPercent()"
                      ></div>
                    </div>
                    <div
                      class="bg-primary absolute size-3.5 -translate-x-1/2 rounded-full shadow-md ring-2 ring-white transition-transform group-hover/bar:scale-125"
                      [style.left.%]="progressPercent()"
                    ></div>
                  </div>
    
                  <div class="flex items-center justify-between gap-2 pt-0.5">
                    <div class="flex items-center gap-1 sm:gap-2">
                      <button
                        ui-button
                        variant="ghost"
                        size="icon"
                        class="size-8 text-white hover:bg-white/15"
                        [attr.aria-label]="isPlaying() ? 'Pause' : 'Play'"
                        (click)="togglePlay()"
                      >
                        @if (isPlaying()) {
                          <lucide-icon [img]="Pause" class="size-4 fill-current" />
                        } @else {
                          <lucide-icon [img]="Play" class="size-4 fill-current" />
                        }
                      </button>
                      <button
                        ui-button
                        variant="ghost"
                        size="icon"
                        class="size-8 text-zinc-300 hover:bg-white/15 hover:text-white"
                        aria-label="Rewind 15 seconds"
                        (click)="skipSeconds(-15)"
                      >
                        <lucide-icon [img]="RotateCcw" class="size-4" />
                      </button>
                      <button
                        ui-button
                        variant="ghost"
                        size="icon"
                        class="size-8 text-zinc-300 hover:bg-white/15 hover:text-white"
                        aria-label="Fast forward 15 seconds"
                        (click)="skipSeconds(15)"
                      >
                        <lucide-icon [img]="RotateCw" class="size-4" />
                      </button>
                      <button
                        ui-button
                        variant="ghost"
                        size="icon"
                        class="size-8 text-zinc-300 hover:bg-white/15 hover:text-white"
                        [attr.aria-label]="isMuted() ? 'Unmute' : 'Mute'"
                        (click)="toggleMute()"
                      >
                        @if (isMuted()) {
                          <lucide-icon [img]="VolumeX" class="text-destructive size-4" />
                        } @else {
                          <lucide-icon [img]="Volume2" class="size-4" />
                        }
                      </button>
                      <div
                        class="flex items-center gap-1 font-mono text-xs font-semibold text-zinc-300 tabular-nums sm:ml-1"
                      >
                        <span class="text-white">{{ formattedCurrentTime() }}</span>
                        <span class="text-muted-foreground">/</span>
                        <span>{{ formattedTotalTime() }}</span>
                      </div>
                    </div>
                    <div class="flex items-center gap-1 sm:gap-2">
                      <button
                        ui-button
                        variant="ghost"
                        size="sm"
                        class="h-8 gap-1 px-2 font-mono text-xs text-zinc-200 hover:bg-white/15 hover:text-white"
                        [attr.aria-label]="'Playback speed ' + playbackSpeed() + 'x'"
                        (click)="cycleSpeed()"
                      >
                        {{ playbackSpeed() }}x
                      </button>
                      <button
                        ui-button
                        variant="ghost"
                        size="icon"
                        class="size-8 text-zinc-300 hover:bg-white/15 hover:text-white"
                        [attr.aria-label]="showCaptions() ? 'Hide captions' : 'Show captions'"
                        (click)="showCaptions.set(!showCaptions())"
                      >
                        <lucide-icon [img]="Subtitles" class="size-4" />
                      </button>
                      <button
                        ui-button
                        variant="ghost"
                        size="icon"
                        class="size-8 text-zinc-300 hover:bg-white/15 hover:text-white"
                        aria-label="Toggle fullscreen"
                        (click)="isFullscreen.set(!isFullscreen())"
                      >
                        <lucide-icon [img]="Maximize2" class="size-4" />
                      </button>
                    </div>
                  </div>
                </div>
              </div>
    
              <!-- Key Takeaways & Downloads -->
              <div ui-card>
                <div ui-card-header>
                  <div class="flex items-center gap-2">
                    <lucide-icon [img]="Lightbulb" class="text-warning size-4" />
                    <h3 ui-card-title class="text-sm font-semibold">Key Takeaways &amp; Starter Files</h3>
                  </div>
                  <p ui-card-description class="text-xs">Download the OKLCH token starter configuration for this lesson.</p>
                </div>
                <div ui-card-content class="space-y-3">
                  <ul class="text-muted-foreground space-y-2 text-xs sm:text-sm">
                    <li class="flex items-start gap-2">
                      <lucide-icon [img]="CheckCircle2" class="text-primary mt-0.5 size-4 shrink-0" />
                      <span
                        >OKLCH lightness is perceptually uniform — step L by 10% and every hue darkens identically.</span
                      >
                    </li>
                    <li class="flex items-start gap-2">
                      <lucide-icon [img]="CheckCircle2" class="text-primary mt-0.5 size-4 shrink-0" />
                      <span>Reduce chroma 15–20% on OLED dark surfaces to stop high-chroma vibration and glare.</span>
                    </li>
                    <li class="flex items-start gap-2">
                      <lucide-icon [img]="CheckCircle2" class="text-primary mt-0.5 size-4 shrink-0" />
                      <span>Keep L ≥ 0.70 for primary brand text on dark surfaces to guarantee APCA Lc 60 minimum.</span>
                    </li>
                  </ul>
                  <div class="flex flex-wrap items-center gap-2">
                    <button
                      ui-button
                      variant="outline"
                      size="sm"
                      class="gap-1.5 text-xs"
                      (click)="handleCopyAsset('tokens')"
                    >
                      @if (copiedAsset() === 'tokens') {
                        <lucide-icon [img]="Check" class="text-success size-3.5" />
                        <span>Copied!</span>
                      } @else {
                        <lucide-icon [img]="FileText" class="size-3.5" />
                        <span>tokens.config.css</span>
                      }
                    </button>
                    <button
                      ui-button
                      variant="outline"
                      size="sm"
                      class="gap-1.5 text-xs"
                      (click)="handleCopyAsset('figma')"
                    >
                      @if (copiedAsset() === 'figma') {
                        <lucide-icon [img]="Check" class="text-success size-3.5" />
                        <span>Copied!</span>
                      } @else {
                        <lucide-icon [img]="Download" class="size-3.5" />
                        <span>Figma Variables (.json)</span>
                      }
                    </button>
                  </div>
                </div>
              </div>
            </section>
    
            <!-- Right Column: Transcript / Notes / Q&A Tabs -->
            <aside class="lg:col-span-4">
              <div ui-card class="flex max-h-[860px] min-h-0 flex-col overflow-hidden">
                <div ui-card-header class="border-border border-b p-4 pb-3">
                  <div class="flex items-center justify-between">
                    <h3 ui-card-title class="text-sm font-semibold">Lesson Companion</h3>
                    <span ui-badge variant="secondary" class="font-mono text-xs tabular-nums">
                      {{ formattedCurrentTime() }} / {{ formattedTotalTime() }}
                    </span>
                  </div>
                </div>
                <ui-tabs
                  [value]="activeTab()"
                  (valueChange)="activeTab.set($any($event))"
                  class="flex min-h-0 flex-1 flex-col"
                >
                  <div ui-tabs-list class="mx-4 mt-3 grid w-auto grid-cols-3">
                    <button ui-tabs-trigger value="transcript" class="gap-1 text-xs">
                      <lucide-icon [img]="MessageSquare" class="size-3.5" />
                      <span>Transcript</span>
                    </button>
                    <button ui-tabs-trigger value="notes" class="gap-1 text-xs">
                      <lucide-icon [img]="FileText" class="size-3.5" />
                      <span>Notes ({{ notesList().length }})</span>
                    </button>
                    <button ui-tabs-trigger value="qa" class="gap-1 text-xs">
                      <lucide-icon [img]="ThumbsUp" class="size-3.5" />
                      <span>Q&amp;A</span>
                    </button>
                  </div>
    
                  <div ui-tabs-content value="transcript" class="flex min-h-0 flex-1 flex-col gap-3 p-4 outline-none">
                    <div class="relative">
                      <lucide-icon
                        [img]="Search"
                        class="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2"
                      />
                      <ui-input
                        [value]="transcriptSearch()"
                        (valueChange)="transcriptSearch.set($event)"
                        placeholder="Search transcript..."
                        class="h-8 pl-8 text-xs"
                      />
                    </div>
                    <div class="min-h-0 flex-1 space-y-1 overflow-auto">
                      @for (item of filteredTranscript(); track item.id) {
                        <button type="button" [class]="transcriptRowClass(item)" (click)="seekTo(item.start)">
                          <div class="flex items-center gap-2">
                            <span class="font-mono text-xs font-semibold tabular-nums">{{ formatTime(item.start) }}</span>
                            <span class="text-muted-foreground text-xs">{{ item.speaker }}</span>
                            @if (isActiveTranscript(item)) {
                              <span ui-badge variant="secondary" class="text-xs">Now playing</span>
                            }
                          </div>
                          <p class="mt-1 text-xs leading-relaxed sm:text-sm">{{ item.text }}</p>
                        </button>
                      }
                      @if (filteredTranscript().length === 0) {
                        <p class="text-muted-foreground py-8 text-center text-xs">
                          No transcript lines match “{{ transcriptSearch() }}”.
                        </p>
                      }
                    </div>
                  </div>
    
                  <div ui-tabs-content value="notes" class="flex min-h-0 flex-1 flex-col gap-3 p-4 outline-none">
                    <div class="flex items-center gap-2">
                      <span ui-badge variant="outline" class="gap-1 font-mono text-xs tabular-nums">
                        <lucide-icon [img]="Clock" class="size-3" />
                        {{ formattedCurrentTime() }}
                      </span>
                      <span class="text-muted-foreground text-xs">Note attaches at the current timestamp.</span>
                    </div>
                    <ui-textarea
                      [value]="newNoteText()"
                      (valueChange)="newNoteText.set($event)"
                      [placeholder]="'Write your thoughts or takeaway at ' + formattedCurrentTime() + '...'"
                      [rows]="2"
                      class="resize-y text-xs leading-relaxed"
                      (keyDown)="handleNoteKeyDown($event)"
                    />
                    <button
                      ui-button
                      size="sm"
                      class="gap-1.5 text-xs"
                      [disabled]="!newNoteText().trim()"
                      (click)="handleAddNote()"
                    >
                      <lucide-icon [img]="Plus" class="size-3.5" />
                      <span>Add Note at {{ formattedCurrentTime() }}</span>
                    </button>
                    <div class="min-h-0 flex-1 space-y-2 overflow-auto">
                      @for (note of notesList(); track note.id) {
                        <div class="border-border bg-muted/30 rounded-lg border p-3">
                          <div class="flex items-center justify-between gap-2">
                            <button
                              type="button"
                              class="text-primary font-mono text-xs font-semibold tabular-nums hover:underline"
                              (click)="seekTo(note.timestamp)"
                            >
                              {{ formatTime(note.timestamp) }}
                            </button>
                            <div class="flex items-center gap-1">
                              <span class="text-muted-foreground text-xs">{{ note.createdAt }}</span>
                              <button
                                ui-button
                                variant="ghost"
                                size="icon-sm"
                                class="size-6"
                                [attr.aria-label]="'Delete note ' + note.id"
                                (click)="handleDeleteNote(note.id)"
                              >
                                <lucide-icon [img]="Trash2" class="size-3.5" />
                              </button>
                            </div>
                          </div>
                          <p class="text-foreground mt-1 text-xs leading-relaxed sm:text-sm">{{ note.text }}</p>
                        </div>
                      }
                    </div>
                  </div>
    
                  <div ui-tabs-content value="qa" class="flex min-h-0 flex-1 flex-col gap-3 p-4 outline-none">
                    <ui-textarea
                      [value]="newQuestionText()"
                      (valueChange)="newQuestionText.set($event)"
                      placeholder="Ask a question about this topic..."
                      [rows]="2"
                      class="text-xs"
                      (keyDown)="handleQuestionKeyDown($event)"
                    />
                    <button
                      ui-button
                      size="sm"
                      class="gap-1.5 text-xs"
                      [disabled]="!newQuestionText().trim()"
                      (click)="handleAddQuestion()"
                    >
                      <lucide-icon [img]="Plus" class="size-3.5" />
                      <span>Post Question</span>
                    </button>
                    <div class="min-h-0 flex-1 space-y-3 overflow-auto">
                      @for (q of questionsList(); track q.id) {
                        <div class="border-border rounded-lg border p-3">
                          <div class="flex items-start justify-between gap-2">
                            <div class="flex items-center gap-2">
                              <div
                                class="bg-primary/10 text-primary flex size-7 items-center justify-center rounded-full text-xs font-bold"
                              >
                                {{ q.avatar }}
                              </div>
                              <div>
                                <p class="text-foreground text-xs font-semibold">{{ q.author }}</p>
                                <p class="text-muted-foreground font-mono text-xs tabular-nums">
                                  {{ formatTime(q.timestamp) }}
                                </p>
                              </div>
                            </div>
                            <button
                              ui-button
                              variant="outline"
                              size="sm"
                              class="h-7 gap-1 px-2 text-xs tabular-nums"
                              [attr.aria-label]="'Upvote question ' + q.id"
                              [class]="q.hasUpvoted ? 'border-primary text-primary' : ''"
                              (click)="toggleUpvote(q)"
                            >
                              <lucide-icon [img]="ThumbsUp" class="size-3" />
                              <span>{{ q.upvotes }}</span>
                            </button>
                          </div>
                          <p class="text-foreground mt-2 text-xs leading-relaxed sm:text-sm">{{ q.question }}</p>
                          @if (q.answer) {
                            <div class="bg-muted/40 border-border mt-2 rounded-md border p-2.5">
                              <p class="flex items-center gap-1.5 text-xs font-semibold">
                                <span>{{ q.answer.author }}</span>
                                <span ui-badge variant="secondary" class="text-xs">{{ q.answer.role }}</span>
                              </p>
                              <p class="text-muted-foreground mt-1 text-xs leading-relaxed">{{ q.answer.text }}</p>
                            </div>
                          }
                        </div>
                      }
                    </div>
                  </div>
                </ui-tabs>
              </div>
            </aside>
          </div>
        </div>
      `,
    })
    export class UiLessonVideoPlayerComponent implements OnDestroy {
      protected readonly ArrowRight = ArrowRight
      protected readonly Bookmark = Bookmark
      protected readonly BookmarkCheck = BookmarkCheck
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly ChevronRight = ChevronRight
      protected readonly Clock = Clock
      protected readonly Download = Download
      protected readonly FileText = FileText
      protected readonly Layers = Layers
      protected readonly Lightbulb = Lightbulb
      protected readonly Maximize2 = Maximize2
      protected readonly MessageSquare = MessageSquare
      protected readonly Pause = Pause
      protected readonly Play = Play
      protected readonly Plus = Plus
      protected readonly RotateCcw = RotateCcw
      protected readonly RotateCw = RotateCw
      protected readonly Search = Search
      protected readonly Subtitles = Subtitles
      protected readonly ThumbsUp = ThumbsUp
      protected readonly Trash2 = Trash2
      protected readonly Volume2 = Volume2
      protected readonly VolumeX = VolumeX
    
      @Input('class') className?: string
    
      readonly isPlaying = signal(false)
      readonly currentTime = signal(384)
      readonly totalDuration = signal(1120)
      readonly bufferedPercent = signal(78)
      readonly volume = signal(85)
      readonly isMuted = signal(false)
      readonly playbackSpeed = signal(1.25)
      readonly speedOptions = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
      readonly showCaptions = signal(true)
      readonly isFullscreen = signal(false)
      readonly activeTab = signal<'transcript' | 'notes' | 'qa'>('transcript')
      readonly transcriptSearch = signal('')
      readonly isCompleted = signal(false)
      readonly isBookmarked = signal(false)
      readonly copiedAsset = signal<string | null>(null)
    
      readonly transcriptItems = signal<TranscriptItem[]>(transcriptSeed.map((t) => ({ ...t })))
      readonly newNoteText = signal('')
      readonly notesList = signal<NoteItem[]>(notesSeed.map((n) => ({ ...n })))
      readonly newQuestionText = signal('')
      readonly questionsList = signal<QuestionItem[]>(
        questionsSeed.map((q) => ({ ...q, answer: q.answer ? { ...q.answer } : undefined })),
      )
    
      private playInterval: ReturnType<typeof setInterval> | null = null
      private copyTimer: ReturnType<typeof setTimeout> | undefined
    
      readonly formattedCurrentTime = computed(() => formatTime(this.currentTime()))
      readonly formattedTotalTime = computed(() => formatTime(this.totalDuration()))
      readonly progressPercent = computed(() => (this.currentTime() / this.totalDuration()) * 100)
    
      readonly activeTranscriptItem = computed(
        () =>
          this.transcriptItems().find((item) => this.currentTime() >= item.start && this.currentTime() < item.end) ??
          this.transcriptItems()[0],
      )
    
      readonly filteredTranscript = computed(() => {
        const query = this.transcriptSearch().trim().toLowerCase()
        if (!query) return this.transcriptItems()
        return this.transcriptItems().filter(
          (item) => item.text.toLowerCase().includes(query) || item.speaker.toLowerCase().includes(query),
        )
      })
    
      get rootClass(): string {
        return cn('bg-background text-foreground w-full space-y-5', this.className)
      }
    
      formatTime(seconds: number): string {
        return formatTime(seconds)
      }
    
      togglePlay(): void {
        this.isPlaying.update((v) => !v)
        if (this.isPlaying()) {
          this.playInterval = setInterval(() => {
            if (this.currentTime() < this.totalDuration()) {
              this.currentTime.update((t) => t + 1)
            } else {
              this.isPlaying.set(false)
              if (this.playInterval) clearInterval(this.playInterval)
            }
          }, 1000 / this.playbackSpeed())
        } else if (this.playInterval) {
          clearInterval(this.playInterval)
        }
      }
    
      seekTo(seconds: number): void {
        this.currentTime.set(Math.max(0, Math.min(seconds, this.totalDuration())))
      }
    
      handleSeekClick(event: MouseEvent): void {
        const target = event.currentTarget as HTMLElement
        const rect = target.getBoundingClientRect()
        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 = this.speedOptions.indexOf(this.playbackSpeed())
        this.playbackSpeed.set(this.speedOptions[(idx + 1) % this.speedOptions.length]!)
        if (this.isPlaying()) {
          if (this.playInterval) clearInterval(this.playInterval)
          this.playInterval = setInterval(() => {
            if (this.currentTime() < this.totalDuration()) {
              this.currentTime.update((t) => t + 1)
            } else {
              this.isPlaying.set(false)
              if (this.playInterval) clearInterval(this.playInterval)
            }
          }, 1000 / this.playbackSpeed())
        }
      }
    
      toggleMute(): void {
        this.isMuted.update((v) => !v)
      }
    
      isActiveTranscript(item: TranscriptItem): boolean {
        return this.activeTranscriptItem()?.id === item.id
      }
    
      transcriptRowClass(item: TranscriptItem): string {
        return cn(
          'w-full rounded-lg border p-3 text-left transition-colors',
          this.isActiveTranscript(item)
            ? 'border-primary/40 bg-primary/5'
            : 'border-transparent hover:bg-muted/40 hover:border-border',
        )
      }
    
      handleAddNote(): void {
        const text = this.newNoteText().trim()
        if (!text) return
        this.notesList.update((prev) => [
          { id: `note-${Date.now()}`, timestamp: this.currentTime(), text, createdAt: 'Just now' },
          ...prev,
        ])
        this.newNoteText.set('')
      }
    
      handleDeleteNote(id: string): void {
        this.notesList.update((prev) => prev.filter((n) => n.id !== id))
      }
    
      handleNoteKeyDown(event: KeyboardEvent): void {
        if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
          event.preventDefault()
          this.handleAddNote()
        }
      }
    
      handleQuestionKeyDown(event: KeyboardEvent): void {
        if (event.key === 'Enter' && !event.shiftKey && !event.metaKey && !event.ctrlKey) {
          event.preventDefault()
          this.handleAddQuestion()
        }
      }
    
      handleAddQuestion(): void {
        const text = this.newQuestionText().trim()
        if (!text) return
        this.questionsList.update((prev) => [
          {
            id: `q-${Date.now()}`,
            timestamp: this.currentTime(),
            author: 'You (Student)',
            avatar: 'ME',
            question: text,
            upvotes: 1,
            hasUpvoted: true,
          },
          ...prev,
        ])
        this.newQuestionText.set('')
      }
    
      toggleUpvote(q: QuestionItem): void {
        this.questionsList.update((prev) =>
          prev.map((item) =>
            item.id === q.id
              ? { ...item, upvotes: item.upvotes + (item.hasUpvoted ? -1 : 1), hasUpvoted: !item.hasUpvoted }
              : item,
          ),
        )
      }
    
      handleCopyAsset(name: string): void {
        this.copiedAsset.set(name)
        clearTimeout(this.copyTimer)
        this.copyTimer = setTimeout(() => this.copiedAsset.set(null), 2000)
      }
    
      ngOnDestroy(): void {
        if (this.playInterval) clearInterval(this.playInterval)
        clearTimeout(this.copyTimer)
      }
    }
    
  • src/app/components/blocks/lesson-video-player/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/lesson-video-player.json