Menu

Change language

GitHub repo

Video Playlist Sidebar

blockmedia

YouTube and Vimeo style video playlist drawer with active playing indicator, durations, auto-play next switch, loop/shuffle controls, and active lesson summary card.

Installation

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

Variants

Loading interactive previews…

Schema

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

VideoResource
interface VideoResource {
  id: string
  name: string
  size: string
  type: 'code' | 'figma' | 'pdf'
}
PlaylistItem
interface PlaylistItem {
  id: string
  index: number
  title: string
  instructor: string
  instructorRole: string
  instructorAvatar: string
  duration: string
  durationSec: number
  category: string
  summary: string
  isCompleted: boolean
  thumbnailGradient: string
  resources: VideoResource[]
}

Files installed (2)

  • src/app/components/blocks/video-playlist-sidebar/video-playlist-sidebar.component.ts27.1 kB
    // YouTube and Vimeo style video playlist drawer with active playing indicator,
    // durations, auto-play next switch, loop/shuffle controls, and active lesson summary
    // card. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, DestroyRef, Input, computed, inject, signal } from '@angular/core'
    import {
      Check,
      CheckCircle2,
      Clock,
      Download,
      FileCode2,
      ListVideo,
      LucideAngularModule,
      Play,
      PlayCircle,
      Repeat,
      Repeat1,
      Search,
      Shuffle,
      SkipForward,
    } 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,
      UiCardFooterComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiProgressComponent } from '@/ui/progress/progress.component'
    import { UiSwitchComponent } from '@/ui/switch/switch.component'
    
    export interface VideoResource {
      id: string
      name: string
      size: string
      type: 'code' | 'figma' | 'pdf'
    }
    
    export interface PlaylistItem {
      id: string
      index: number
      title: string
      instructor: string
      instructorRole: string
      instructorAvatar: string
      duration: string
      durationSec: number
      category: string
      summary: string
      isCompleted: boolean
      thumbnailGradient: string
      resources: VideoResource[]
    }
    
    const initialPlaylist: PlaylistItem[] = [
      {
        id: 'vid-01',
        index: 1,
        title: '01. Introduction to Component Registries',
        instructor: 'Marcus Vance',
        instructorRole: 'Principal Design Engineer',
        instructorAvatar: 'MV',
        duration: '14:20',
        durationSec: 860,
        category: 'Architecture',
        summary: 'Understanding unbundled component distribution, registry manifests, and own-your-code paradigms.',
        isCompleted: true,
        thumbnailGradient: 'from-slate-800 to-zinc-950',
        resources: [
          { id: 'res-1a', name: 'registry-schema-spec.pdf', size: '1.2 MB', type: 'pdf' },
          { id: 'res-1b', name: 'starter-registry.json', size: '48 KB', type: 'code' },
        ],
      },
      {
        id: 'vid-02',
        index: 2,
        title: '02. Headless Reka UI Primitives',
        instructor: 'Elena Rostova',
        instructorRole: 'Staff Frontend Engineer',
        instructorAvatar: 'ER',
        duration: '18:45',
        durationSec: 1125,
        category: 'Accessibility',
        summary: 'Composition with asChild, polymorphic slot mechanics, and ARIA state tree management.',
        isCompleted: true,
        thumbnailGradient: 'from-zinc-800 to-slate-950',
        resources: [
          { id: 'res-2a', name: 'reka-composition-cheatsheet.pdf', size: '2.4 MB', type: 'pdf' },
          { id: 'res-2b', name: 'accessible-dialog.vue', size: '14 KB', type: 'code' },
        ],
      },
      {
        id: 'vid-03',
        index: 3,
        title: '03. Building with OKLCH Tokens',
        instructor: 'Sarah Connor',
        instructorRole: 'Design Systems Architect',
        instructorAvatar: 'SC',
        duration: '22:10',
        durationSec: 1330,
        category: 'Color Systems',
        summary: 'Perceptually uniform color spaces, wide-gamut Display P3 gamut mapping, and dark mode contrast.',
        isCompleted: false,
        thumbnailGradient: 'from-slate-850 to-zinc-950',
        resources: [
          { id: 'res-3a', name: 'tokens.config.css', size: '14.2 KB', type: 'code' },
          { id: 'res-3b', name: 'oklch-palette-matrix.fig', size: '4.8 MB', type: 'figma' },
          { id: 'res-3c', name: 'apca-contrast-guide.pdf', size: '1.1 MB', type: 'pdf' },
        ],
      },
      {
        id: 'vid-04',
        index: 4,
        title: '04. Micro-Interactions & Spring Physics',
        instructor: 'Alex Chen',
        instructorRole: 'Motion Design Lead',
        instructorAvatar: 'AC',
        duration: '19:35',
        durationSec: 1175,
        category: 'Motion & Physics',
        summary: 'Crafting tactile UI states, gesture dampening, spring curves, and 120fps hardware acceleration.',
        isCompleted: false,
        thumbnailGradient: 'from-zinc-800 to-zinc-950',
        resources: [
          { id: 'res-4a', name: 'spring-curves-preset.ts', size: '8.4 KB', type: 'code' },
          { id: 'res-4b', name: 'gesture-lab.fig', size: '3.6 MB', type: 'figma' },
        ],
      },
      {
        id: 'vid-05',
        index: 5,
        title: '05. Multi-Framework Parity Testing',
        instructor: 'Marcus Vance',
        instructorRole: 'Principal Design Engineer',
        instructorAvatar: 'MV',
        duration: '16:50',
        durationSec: 1010,
        category: 'Testing & Quality',
        summary: 'Automating visual regression tests and unit tests across dual Vue 3 and React 19 packages.',
        isCompleted: false,
        thumbnailGradient: 'from-slate-800 to-slate-950',
        resources: [
          { id: 'res-5a', name: 'parity-test-matrix.ts', size: '22.0 KB', type: 'code' },
          { id: 'res-5b', name: 'playwright.config.ts', size: '6.5 KB', type: 'code' },
        ],
      },
      {
        id: 'vid-06',
        index: 6,
        title: '06. CI/CD Deployment to Cloudflare',
        instructor: 'David Kim',
        instructorRole: 'DevOps & Cloud Engineer',
        instructorAvatar: 'DK',
        duration: '25:15',
        durationSec: 1515,
        category: 'DevOps & Edge',
        summary: 'Automating multi-framework build chains, registry sync, and edge deployment via Wrangler.',
        isCompleted: false,
        thumbnailGradient: 'from-zinc-850 to-zinc-950',
        resources: [
          { id: 'res-6a', name: 'wrangler.toml', size: '3.4 KB', type: 'code' },
          { id: 'res-6b', name: 'deploy-pipeline.yml', size: '5.1 KB', type: 'code' },
        ],
      },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-video-playlist-sidebar, [ui-video-playlist-sidebar]',
      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,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardFooterComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiProgressComponent,
        UiSwitchComponent,
      ],
      template: `
        <div data-slot="video-playlist-sidebar" [class]="rootClass">
          <!-- Main Playlist Control Card -->
          <div ui-card class="border-border bg-card shadow-xs">
            <div ui-card-header class="space-y-3 pb-3 sm:pb-4">
              <!-- Badge row -->
              <div class="flex flex-wrap items-center justify-between gap-2">
                <div class="flex items-center gap-2">
                  <span ui-badge variant="secondary" class="gap-1.5 text-xs font-semibold">
                    <lucide-icon [img]="ListVideo" class="text-primary size-3.5" />
                    <span>Video Playlist</span>
                  </span>
                  <span ui-badge variant="outline" class="text-muted-foreground gap-1 font-mono text-xs">
                    <lucide-icon [img]="Clock" class="size-3" />
                    <span>1h 56m Total</span>
                  </span>
                </div>
                <span class="text-muted-foreground text-xs font-medium">UIPKGE Masterclass Series</span>
              </div>
    
              <!-- Title & Subtitle -->
              <div class="space-y-1">
                <h2 ui-card-title class="text-foreground text-lg font-bold tracking-tight sm:text-xl">
                  Mastering Full-Stack UI Engineering
                </h2>
                <p ui-card-description class="text-xs">
                  Curated playlist on headless primitives, OKLCH token systems, fluid motion physics, and registry CI
                  pipelines.
                </p>
              </div>
    
              <!-- Progress bar and counter -->
              <div class="bg-muted/40 border-border/70 space-y-2 rounded-lg border p-3">
                <div class="flex items-center justify-between text-xs">
                  <span class="text-foreground font-semibold">
                    <span class="font-mono tabular-nums">{{ completedCount() }}</span> /
                    <span class="font-mono tabular-nums">{{ totalCount() }}</span> Videos Completed
                  </span>
                  <span class="text-primary font-mono font-bold tabular-nums">{{ progressPercent() }}%</span>
                </div>
                <ui-progress [value]="progressPercent()" class="h-2" />
              </div>
    
              <!-- Toolbar: Autoplay switch, Loop & Shuffle toggles -->
              <div class="flex flex-wrap items-center justify-between gap-3 pt-1">
                <!-- Autoplay next switch -->
                <div class="flex items-center gap-2.5">
                  <ui-switch id="autoplay-switch" [checked]="autoPlay()" (checkedChange)="autoPlay.set($event)" size="sm" />
                  <label
                    for="autoplay-switch"
                    class="text-foreground flex cursor-pointer items-center gap-1.5 text-xs font-medium select-none"
                  >
                    <lucide-icon [img]="Play" class="text-primary fill-primary size-3.5" />
                    <span>Autoplay Next Video</span>
                  </label>
                </div>
    
                <!-- Loop and Shuffle Buttons -->
                <div class="flex items-center gap-1.5">
                  <button
                    ui-button
                    variant="ghost"
                    size="sm"
                    [class]="loopButtonClass()"
                    [attr.aria-pressed]="isLooping()"
                    aria-label="Toggle loop playlist"
                    (click)="isLooping.set(!isLooping())"
                  >
                    @if (isLooping()) {
                      <lucide-icon [img]="Repeat1" class="size-3.5" />
                    } @else {
                      <lucide-icon [img]="Repeat" class="size-3.5" />
                    }
                    <span>Loop</span>
                  </button>
    
                  <button
                    ui-button
                    variant="ghost"
                    size="sm"
                    [class]="shuffleButtonClass()"
                    [attr.aria-pressed]="isShuffled()"
                    aria-label="Toggle shuffle playlist"
                    (click)="isShuffled.set(!isShuffled())"
                  >
                    <lucide-icon [img]="Shuffle" class="size-3.5" />
                    <span>Shuffle</span>
                  </button>
                </div>
              </div>
            </div>
          </div>
    
          <!-- Currently Active Video Card -->
          <div
            ui-card
            class="border-primary/40 from-primary/5 via-card to-card relative overflow-hidden bg-gradient-to-br shadow-xs"
          >
            <div class="bg-primary absolute top-0 right-0 left-0 h-0.5"></div>
            <div ui-card-header class="pb-3">
              <div class="flex flex-wrap items-center justify-between gap-2">
                <div class="flex items-center gap-2">
                  <span ui-badge variant="default" class="gap-1.5 text-xs font-semibold">
                    <span class="size-1.5 animate-pulse rounded-full bg-white"></span>
                    <span>Now Playing</span>
                  </span>
                  <span ui-badge variant="outline" class="font-mono text-xs font-medium">
                    Lesson {{ activeVideo().index }} of {{ totalCount() }}
                  </span>
                </div>
                <span class="text-muted-foreground font-mono text-xs font-semibold tabular-nums">
                  {{ activeVideo().duration }}
                </span>
              </div>
    
              <div class="space-y-1 pt-1">
                <h3 ui-card-title class="text-foreground text-base font-bold tracking-tight sm:text-lg">
                  {{ activeVideo().title }}
                </h3>
                <p ui-card-description class="text-xs leading-relaxed">
                  {{ activeVideo().summary }}
                </p>
              </div>
    
              <!-- Instructor and Metadata -->
              <div class="flex flex-wrap items-center justify-between gap-3 pt-2">
                <div class="flex items-center gap-2">
                  <div
                    class="border-primary/30 bg-primary/15 text-primary flex size-7 items-center justify-center rounded-full border text-xs font-bold"
                  >
                    {{ activeVideo().instructorAvatar }}
                  </div>
                  <div>
                    <p class="text-foreground text-xs font-semibold">{{ activeVideo().instructor }}</p>
                    <p class="text-muted-foreground text-xs">
                      {{ activeVideo().instructorRole }} · {{ activeVideo().category }}
                    </p>
                  </div>
                </div>
    
                <!-- Video status controls -->
                <div class="flex items-center gap-2">
                  <button
                    ui-button
                    [variant]="activeVideo().isCompleted ? 'secondary' : 'outline'"
                    size="sm"
                    class="h-7.5 gap-1.5 px-2.5 text-xs font-medium"
                    (click)="toggleComplete(activeVideo().id)"
                  >
                    @if (activeVideo().isCompleted) {
                      <lucide-icon [img]="CheckCircle2" class="text-success size-3.5" />
                    } @else {
                      <lucide-icon [img]="Check" class="size-3.5" />
                    }
                    <span>{{ activeVideo().isCompleted ? 'Completed' : 'Mark as Watched' }}</span>
                  </button>
    
                  <button
                    ui-button
                    size="sm"
                    class="h-7.5 gap-1.5 px-2.5 text-xs font-semibold shadow-xs"
                    (click)="nextVideo()"
                  >
                    <span>Next</span>
                    <lucide-icon [img]="SkipForward" class="size-3.5" />
                  </button>
                </div>
              </div>
            </div>
    
            <!-- Downloadable Lesson Exercise Files -->
            <div ui-card-content class="border-border/60 bg-muted/20 border-t pt-3 pb-3.5">
              <div class="space-y-2">
                <div class="flex items-center justify-between">
                  <p class="text-foreground flex items-center gap-1.5 text-xs font-bold tracking-tight uppercase">
                    <lucide-icon [img]="FileCode2" class="text-primary size-3.5" />
                    <span>Lesson Exercise Files</span>
                  </p>
                  <span class="text-muted-foreground font-mono text-xs">{{ activeVideo().resources.length }} Files</span>
                </div>
    
                <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
                  @for (res of activeVideo().resources; track res.id) {
                    <div
                      class="border-border/80 bg-card hover:bg-muted/40 flex items-center justify-between gap-2 rounded-lg border p-2 text-xs transition-colors"
                    >
                      <div class="flex min-w-0 items-center gap-2">
                        <span
                          class="border-primary/20 bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded font-mono text-xs font-bold"
                        >
                          {{ res.type === 'code' ? 'TS' : res.type === 'figma' ? 'FIG' : 'PDF' }}
                        </span>
                        <div class="min-w-0">
                          <p class="text-foreground truncate text-xs font-medium">{{ res.name }}</p>
                          <p class="text-muted-foreground font-mono text-xs">{{ res.size }}</p>
                        </div>
                      </div>
    
                      <button
                        ui-button
                        aria-label="Download attachment"
                        variant="outline"
                        size="sm"
                        class="h-6.5 shrink-0 gap-1 px-2 text-xs font-medium"
                        (click)="handleDownload(res.id)"
                      >
                        @if (downloadedAssets()[res.id]) {
                          <lucide-icon [img]="Check" class="text-success size-3" />
                        } @else {
                          <lucide-icon [img]="Download" class="size-3" />
                        }
                        <span>{{ downloadedAssets()[res.id] ? 'Saved' : 'Get' }}</span>
                      </button>
                    </div>
                  }
                </div>
              </div>
            </div>
          </div>
    
          <!-- Playlist Queue & List Header -->
          <div ui-card class="border-border bg-card shadow-xs">
            <div ui-card-header class="space-y-3 pb-3">
              <div class="flex flex-wrap items-center justify-between gap-2">
                <div class="flex items-center gap-2">
                  <h3 ui-card-title class="text-foreground text-sm font-bold tracking-tight">Playlist Queue</h3>
                  <span ui-badge variant="secondary" class="font-mono text-xs">{{ totalCount() }} Videos</span>
                </div>
                <span class="text-muted-foreground text-xs font-medium">Click any lesson to play</span>
              </div>
    
              <!-- Search / Filter input -->
              <div class="relative">
                <lucide-icon [img]="Search" class="text-muted-foreground absolute top-2.5 left-2.5 size-3.5" />
                <input
                  [value]="searchQuery()"
                  (input)="searchQuery.set($any($event.target).value)"
                  type="text"
                  placeholder="Filter playlist by title, instructor, or topic..."
                  class="border-input bg-background placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 h-8 w-full rounded-md border pr-3 pl-8 text-xs shadow-xs outline-none focus-visible:ring-[3px]"
                />
              </div>
            </div>
    
            <!-- Video Items List -->
            <div ui-card-content class="divide-border/60 divide-y p-0">
              @for (video of filteredVideos(); track video.id) {
                <div
                  [class]="queueItemClass(video.id)"
                  tabindex="0"
                  role="button"
                  [attr.aria-label]="'Play ' + video.title"
                  [attr.aria-current]="activeVideoId() === video.id ? 'true' : null"
                  (click)="selectVideo(video.id)"
                  (keydown.enter)="selectVideo(video.id)"
                  (keydown.space)="$event.preventDefault(); selectVideo(video.id)"
                >
                  @if (activeVideoId() === video.id) {
                    <div class="bg-primary absolute top-2 bottom-2 left-0 w-1 rounded-r shadow-xs"></div>
                  }
    
                  <!-- Left Section: Index / Status Indicator + Video Thumbnail -->
                  <div class="flex min-w-0 items-center gap-3">
                    <div class="flex size-6 shrink-0 items-center justify-center">
                      @if (activeVideoId() === video.id) {
                        <div class="flex h-3.5 w-3.5 items-end justify-center gap-0.5" title="Playing">
                          <span class="bg-primary h-3 w-0.5 animate-pulse rounded-full"></span>
                          <span
                            class="bg-primary h-4 w-0.5 animate-pulse rounded-full"
                            style="animation-delay: 150ms"
                          ></span>
                          <span
                            class="bg-primary h-2 w-0.5 animate-pulse rounded-full"
                            style="animation-delay: 300ms"
                          ></span>
                        </div>
                      } @else if (video.isCompleted) {
                        <lucide-icon [img]="CheckCircle2" class="text-success size-4 shrink-0" />
                      } @else {
                        <span class="text-muted-foreground font-mono text-xs font-semibold tabular-nums">
                          {{ video.index.toString().padStart(2, '0') }}
                        </span>
                      }
                    </div>
    
                    <!-- 16:9 Video Thumbnail Preview -->
                    <div [class]="thumbnailClass(video.thumbnailGradient)">
                      <div class="absolute inset-0 flex items-center justify-center">
                        <lucide-icon [img]="PlayCircle" [class]="playWatermarkClass(video.id)" />
                      </div>
    
                      <div
                        class="absolute right-1 bottom-1 rounded bg-black/85 px-1 py-0.5 font-mono text-xs font-semibold text-white tabular-nums backdrop-blur-xs"
                      >
                        {{ video.duration }}
                      </div>
                    </div>
    
                    <!-- Video Title & Instructor Info -->
                    <div class="min-w-0 space-y-0.5">
                      <p [class]="videoTitleClass(video.id)">
                        {{ video.title }}
                      </p>
                      <p class="text-muted-foreground truncate text-xs">
                        <span>{{ video.instructor }}</span>
                        <span class="mx-1.5">·</span>
                        <span>{{ video.category }}</span>
                      </p>
                    </div>
                  </div>
    
                  <!-- Right Section: Status Badge & Quick Watch Toggle -->
                  <div class="flex shrink-0 items-center gap-2">
                    @if (activeVideoId() === video.id) {
                      <span ui-badge variant="default" class="hidden px-2 py-0.5 text-xs font-semibold sm:inline-flex">
                        Playing
                      </span>
                    } @else if (video.isCompleted) {
                      <span
                        ui-badge
                        variant="outline"
                        class="text-success hidden gap-1 px-1.5 py-0.5 text-xs font-medium sm:inline-flex"
                      >
                        <lucide-icon [img]="Check" class="size-3" />
                        <span>Watched</span>
                      </span>
                    }
    
                    <button
                      ui-button
                      variant="ghost"
                      size="icon"
                      class="text-muted-foreground hover:text-foreground size-7 shrink-0"
                      [attr.aria-label]="video.isCompleted ? 'Mark as unwatched' : 'Mark as watched'"
                      (click)="$event.stopPropagation(); toggleComplete(video.id)"
                    >
                      @if (video.isCompleted) {
                        <lucide-icon [img]="CheckCircle2" class="text-success size-4" />
                      } @else if (activeVideoId() === video.id) {
                        <lucide-icon [img]="Play" class="text-primary size-3.5 fill-current" />
                      } @else {
                        <lucide-icon [img]="Check" class="text-muted-foreground/60 hover:text-foreground size-3.5" />
                      }
                    </button>
                  </div>
                </div>
              }
    
              @if (filteredVideos().length === 0) {
                <div class="text-muted-foreground p-8 text-center text-xs">No lessons match your search criteria.</div>
              }
            </div>
    
            <div ui-card-footer class="border-border/60 bg-muted/20 flex items-center justify-between border-t p-3 text-xs">
              <span class="text-muted-foreground">Mastering Full-Stack UI Engineering</span>
              <span class="text-muted-foreground font-mono font-medium tabular-nums">
                {{ completedCount() }}/{{ totalCount() }} Completed ({{ progressPercent() }}%)
              </span>
            </div>
          </div>
        </div>
      `,
    })
    export class UiVideoPlaylistSidebarComponent {
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Clock = Clock
      protected readonly Download = Download
      protected readonly FileCode2 = FileCode2
      protected readonly ListVideo = ListVideo
      protected readonly Play = Play
      protected readonly PlayCircle = PlayCircle
      protected readonly Repeat = Repeat
      protected readonly Repeat1 = Repeat1
      protected readonly Search = Search
      protected readonly Shuffle = Shuffle
      protected readonly SkipForward = SkipForward
    
      @Input() set initialVideoId(v: string) {
        if (v !== undefined) this.activeVideoId.set(v)
      }
      @Input('class') className?: string
    
      readonly playlist = signal<PlaylistItem[]>(initialPlaylist.map((v) => ({ ...v, resources: [...v.resources] })))
      readonly activeVideoId = signal('vid-03')
      readonly autoPlay = signal(true)
      readonly isLooping = signal(false)
      readonly isShuffled = signal(false)
      readonly searchQuery = signal('')
      readonly downloadedAssets = signal<Record<string, boolean>>({})
    
      private downloadTimers: Record<string, ReturnType<typeof setTimeout>> = {}
    
      constructor() {
        inject(DestroyRef).onDestroy(() => {
          for (const t of Object.values(this.downloadTimers)) clearTimeout(t)
        })
      }
    
      readonly activeVideo = computed(() => {
        const list = this.playlist()
        return list.find((item) => item.id === this.activeVideoId()) ?? list[0]
      })
    
      readonly completedCount = computed(() => this.playlist().filter((item) => item.isCompleted).length)
      readonly totalCount = computed(() => this.playlist().length)
    
      readonly progressPercent = computed(() => {
        const total = this.totalCount()
        if (total === 0) return 0
        return Math.round((this.completedCount() / total) * 100)
      })
    
      readonly filteredVideos = computed(() => {
        const query = this.searchQuery().trim().toLowerCase()
        if (!query) return this.playlist()
        return this.playlist().filter(
          (item) =>
            item.title.toLowerCase().includes(query) ||
            item.instructor.toLowerCase().includes(query) ||
            item.category.toLowerCase().includes(query),
        )
      })
    
      get rootClass(): string {
        return cn('text-foreground mx-auto w-full max-w-2xl space-y-4', this.className)
      }
    
      loopButtonClass(): string {
        return cn(
          'h-7.5 gap-1.5 px-2.5 text-xs font-medium',
          this.isLooping()
            ? 'border-primary/30 bg-primary/10 text-primary border font-semibold'
            : 'text-muted-foreground hover:text-foreground',
        )
      }
    
      shuffleButtonClass(): string {
        return cn(
          'h-7.5 gap-1.5 px-2.5 text-xs font-medium',
          this.isShuffled()
            ? 'border-primary/30 bg-primary/10 text-primary border font-semibold'
            : 'text-muted-foreground hover:text-foreground',
        )
      }
    
      queueItemClass(id: string): string {
        return cn(
          'group relative flex cursor-pointer items-center justify-between gap-3 p-3 transition-colors duration-150',
          this.activeVideoId() === id
            ? 'border-primary/50 bg-primary/10 ring-primary/20 shadow-xs ring-1'
            : 'hover:bg-muted/40',
        )
      }
    
      thumbnailClass(gradient: string): string {
        return cn(
          'relative aspect-video w-24 shrink-0 overflow-hidden rounded-md border border-zinc-800 bg-zinc-950 bg-gradient-to-br shadow-xs sm:w-28',
          gradient,
        )
      }
    
      playWatermarkClass(id: string): string {
        return cn(
          'size-5 transition-transform group-hover:scale-110',
          this.activeVideoId() === id ? 'text-primary fill-primary/20' : 'text-muted-foreground/80',
        )
      }
    
      videoTitleClass(id: string): string {
        return cn(
          'truncate text-xs font-semibold transition-colors sm:text-sm',
          this.activeVideoId() === id ? 'text-primary font-bold' : 'text-foreground group-hover:text-primary',
        )
      }
    
      selectVideo(id: string): void {
        this.activeVideoId.set(id)
      }
    
      toggleComplete(id: string): void {
        this.playlist.update((prev) =>
          prev.map((item) => (item.id === id ? { ...item, isCompleted: !item.isCompleted } : item)),
        )
      }
    
      nextVideo(): void {
        const list = this.playlist()
        const currentIndex = list.findIndex((item) => item.id === this.activeVideoId())
        if (currentIndex === -1) return
    
        if (this.isShuffled()) {
          const remaining = list.filter((item) => item.id !== this.activeVideoId())
          if (remaining.length > 0) {
            const randomIndex = Math.floor(Math.random() * remaining.length)
            this.activeVideoId.set(remaining[randomIndex].id)
          }
          return
        }
    
        if (currentIndex < list.length - 1) {
          this.activeVideoId.set(list[currentIndex + 1].id)
        } else if (this.isLooping()) {
          this.activeVideoId.set(list[0].id)
        }
      }
    
      handleDownload(assetId: string): void {
        this.downloadedAssets.update((prev) => ({ ...prev, [assetId]: true }))
        clearTimeout(this.downloadTimers[assetId])
        this.downloadTimers[assetId] = setTimeout(() => {
          this.downloadedAssets.update((prev) => ({ ...prev, [assetId]: false }))
        }, 2000)
      }
    }
    
  • src/app/components/blocks/video-playlist-sidebar/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/video-playlist-sidebar.json