Video Playlist Sidebar
blockmediaYouTube and Vimeo style video playlist drawer with active playing indicator, durations, auto-play next switch, loop/shuffle controls, and active lesson summary card.
Installation
$pnpm dlx uipkge-ng@latest add video-playlist-sidebar$npx uipkge-ng@latest add video-playlist-sidebar$yarn dlx uipkge-ng@latest add video-playlist-sidebar$bunx uipkge-ng@latest add video-playlist-sidebarManifest:
https://uipkge.dev/r/angular/video-playlist-sidebar.jsonInstalls to:src/app/components/blocks/video-playlist-sidebar/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.
VideoResourceinterface VideoResource {
id: string
name: string
size: string
type: 'code' | 'figma' | 'pdf'
}PlaylistIteminterface 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
export { UiVideoPlaylistSidebarComponent, type VideoResource, type PlaylistItem, } from './video-playlist-sidebar.component'
Raw manifest:https://uipkge.dev/r/angular/video-playlist-sidebar.json