
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
Online lecture video player with playback speed selector, interactive timestamped transcript, and note-taking drawer.
Also available for React ->$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/lesson-video-player.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/lesson-video-player.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/lesson-video-player.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/lesson-video-player.jsonnpx shadcn-vue@latest add @uipkge/lesson-video-playerInstalls to:app/components/blocks/Type aliases exported from this item's source. Use these to shape the data you pass in.
TranscriptIteminterface TranscriptItem {
id: string
start: number
end: number
speaker: string
text: string
}NoteIteminterface NoteItem {
id: string
timestamp: number
text: string
createdAt: string
}QuestionIteminterface QuestionItem {
id: string
timestamp: number
author: string
avatar: string
question: string
upvotes: number
hasUpvoted?: boolean
answer?: {
author: string
role: string
text: string
isInstructor?: boolean
}
}<script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue'
import {
ArrowRight,
Bookmark,
BookmarkCheck,
Check,
CheckCircle2,
ChevronRight,
Clock,
Download,
FileCode2,
FileText,
Layers,
Lightbulb,
Maximize2,
MessageSquare,
MessageSquareQuote,
Minimize2,
Pause,
Play,
Plus,
RotateCcw,
RotateCw,
Search,
Subtitles,
ThumbsUp,
Trash2,
Volume1,
Volume2,
VolumeX,
} from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Progress } from '@/components/ui/progress'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
// Video Playback State
const isPlaying = ref(false)
const currentTime = ref(384) // 06:24
const totalDuration = ref(1120) // 18:40
const bufferedPercent = ref(78)
const volume = ref(85)
const isMuted = ref(false)
const playbackSpeed = ref(1.25)
const speedOptions = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
const showCaptions = ref(true)
const isFullscreen = ref(false)
const activeTab = ref<'transcript' | 'notes' | 'qa'>('transcript')
const transcriptSearch = ref('')
const isCompleted = ref(false)
const isBookmarked = ref(false)
const copiedAsset = ref<string | null>(null)
// Timer ticker when video is playing
let playInterval: ReturnType<typeof setInterval> | null = null
function togglePlay() {
isPlaying.value = !isPlaying.value
if (isPlaying.value) {
playInterval = setInterval(() => {
if (currentTime.value < totalDuration.value) {
currentTime.value += 1
} else {
isPlaying.value = false
if (playInterval) clearInterval(playInterval)
}
}, 1000 / playbackSpeed.value)
} else if (playInterval) {
clearInterval(playInterval)
}
}
onUnmounted(() => {
if (playInterval) clearInterval(playInterval)
})
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')}`
}
const formattedCurrentTime = computed(() => formatTime(currentTime.value))
const formattedTotalTime = computed(() => formatTime(totalDuration.value))
const progressPercent = computed(() => (currentTime.value / totalDuration.value) * 100)
function seekTo(seconds: number) {
currentTime.value = Math.max(0, Math.min(seconds, totalDuration.value))
}
function handleSeekClick(event: MouseEvent) {
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
const clickX = event.clientX - rect.left
const ratio = Math.max(0, Math.min(1, clickX / rect.width))
seekTo(Math.round(ratio * totalDuration.value))
}
function skipSeconds(delta: number) {
seekTo(currentTime.value + delta)
}
function cycleSpeed() {
const currentIndex = speedOptions.indexOf(playbackSpeed.value)
const nextIndex = (currentIndex + 1) % speedOptions.length
playbackSpeed.value = speedOptions[nextIndex]
if (isPlaying.value) {
// Restart interval with new speed
if (playInterval) clearInterval(playInterval)
playInterval = setInterval(() => {
if (currentTime.value < totalDuration.value) {
currentTime.value += 1
} else {
isPlaying.value = false
if (playInterval) clearInterval(playInterval)
}
}, 1000 / playbackSpeed.value)
}
}
function toggleMute() {
isMuted.value = !isMuted.value
}
// Transcript Data
interface TranscriptItem {
id: string
start: number
end: number
speaker: string
text: string
}
const transcriptItems = ref<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.',
},
])
const activeTranscriptItem = computed(() => {
return (
transcriptItems.value.find((item) => currentTime.value >= item.start && currentTime.value < item.end) ||
transcriptItems.value[0]
)
})
const filteredTranscript = computed(() => {
const query = transcriptSearch.value.trim().toLowerCase()
if (!query) return transcriptItems.value
return transcriptItems.value.filter(
(item) => item.text.toLowerCase().includes(query) || item.speaker.toLowerCase().includes(query),
)
})
// Notes Data
interface NoteItem {
id: string
timestamp: number
text: string
createdAt: string
}
const newNoteText = ref('')
const notesList = ref<NoteItem[]>([
{
id: 'n-1',
timestamp: 102, // 01:42
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, // 04:12
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, // 06:24
text: 'Lightness rule: Keep L >= 0.70 for primary brand text on dark surfaces to guarantee APCA Lc 60 minimum.',
createdAt: 'Just now',
},
])
function handleAddNote() {
const text = newNoteText.value.trim()
if (!text) return
notesList.value.unshift({
id: `note-${Date.now()}`,
timestamp: currentTime.value,
text,
createdAt: 'Just now',
})
newNoteText.value = ''
}
function handleDeleteNote(id: string) {
notesList.value = notesList.value.filter((n) => n.id !== id)
}
// Q&A Data
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
}
}
const newQuestionText = ref('')
const questionsList = ref<QuestionItem[]>([
{
id: 'q-1',
timestamp: 225, // 03:45
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, // 06:20
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.',
},
},
])
function handleAddQuestion() {
const text = newQuestionText.value.trim()
if (!text) return
questionsList.value.unshift({
id: `q-${Date.now()}`,
timestamp: currentTime.value,
author: 'You (Student)',
avatar: 'ME',
question: text,
upvotes: 1,
hasUpvoted: true,
})
newQuestionText.value = ''
}
function toggleUpvote(q: QuestionItem) {
if (q.hasUpvoted) {
q.upvotes -= 1
q.hasUpvoted = false
} else {
q.upvotes += 1
q.hasUpvoted = true
}
}
function handleCopyAsset(name: string) {
copiedAsset.value = name
setTimeout(() => {
copiedAsset.value = null
}, 2000)
}
</script>
<template>
<div data-slot="lesson-video-player" class="bg-background text-foreground w-full space-y-5">
<!-- 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">
<!-- Left: Course Context & Lesson Title -->
<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>
<ChevronRight class="text-muted-foreground size-3.5" />
<Badge variant="outline" class="gap-1 text-xs font-semibold">
<Layers class="text-primary size-3" />
<span>Module 1: Design Tokens Architecture</span>
</Badge>
<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>
<!-- Right: Progress Indicator & Navigation Buttons -->
<div class="flex flex-wrap items-center gap-2.5">
<!-- Course Progress Pill -->
<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">
<Progress :model-value="33" class="h-1.5" />
</div>
</div>
<!-- Bookmark Button -->
<Button
variant="outline"
size="icon"
class="size-8.5"
:aria-label="isBookmarked ? 'Remove bookmark' : 'Bookmark lesson'"
@click="isBookmarked = !isBookmarked"
>
<BookmarkCheck v-if="isBookmarked" class="text-primary size-4" />
<Bookmark v-else class="text-muted-foreground size-4" />
</Button>
<!-- Complete Lesson Button -->
<Button
:variant="isCompleted ? 'default' : 'outline'"
size="sm"
class="h-8.5 gap-1.5 text-xs font-semibold shadow-xs"
@click="isCompleted = !isCompleted"
>
<CheckCircle2 v-if="isCompleted" class="size-3.5" />
<Check v-else class="size-3.5" />
<span>{{ isCompleted ? 'Completed' : 'Mark Complete' }}</span>
</Button>
<!-- Next Lesson Button -->
<Button variant="default" size="sm" class="h-8.5 gap-1.5 text-xs font-semibold shadow-xs">
<span>Next Lesson</span>
<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 (8 cols) -->
<section class="space-y-5 lg:col-span-8">
<!-- Video Screen Container (Dark Video Canvas) -->
<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"
>
<!-- Top Video Overlay Info Bar -->
<div class="z-20 flex items-center justify-between p-3.5 sm:p-4">
<div class="flex items-center gap-2">
<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
</Badge>
<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
</Badge>
</div>
<!-- Instructor Watermark -->
<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 class="font-medium">Sarah Connor</span>
<span class="text-muted-foreground">·</span>
<span class="text-muted-foreground">Principal Design Engineer</span>
</div>
</div>
<!-- Video Presentation Slide & Diagram Visual -->
<div class="relative my-auto flex flex-col items-center justify-center px-6 py-4 text-center">
<!-- Simulated Code & Color Token Slide Matrix -->
<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 class="bg-warning/80 size-2.5 rounded-full" />
<span class="bg-success/80 size-2.5 rounded-full" />
</div>
<span class="text-muted-foreground font-mono text-xs">tokens.config.css — OKLCH Palette Engine</span>
</div>
<Badge variant="outline" class="border-success/40 bg-success/10 text-success font-mono text-xs">
APCA Lc 74 (AAA)
</Badge>
</div>
<!-- Code Syntax Sample -->
<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">@theme inline</span> {
<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>
}</code></pre>
<!-- Color Palette Swatch Strip -->
<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 class="h-5 flex-1 rounded bg-[oklch(0.80_0.10_259.8)] shadow-xs" title="80% L" />
<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 class="h-5 flex-1 rounded bg-[oklch(0.40_0.15_259.8)] shadow-xs" title="40% L" />
<div class="h-5 flex-1 rounded bg-[oklch(0.18_0.02_259.8)] shadow-xs" title="18% L (Dark)" />
</div>
</div>
</div>
<!-- Big Center Play / Pause Floating Button -->
<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"
:aria-label="isPlaying ? 'Pause video' : 'Play video'"
@click="togglePlay"
>
<Pause v-if="isPlaying" class="size-6 fill-current" />
<Play v-else class="ml-0.5 size-6 fill-current" />
</button>
</div>
<!-- Captions Subtitles Overlay Box -->
<div
v-if="showCaptions && activeTranscriptItem"
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>
<!-- Custom Player Controls Bar (Bottom Overlay) -->
<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"
>
<!-- Scrubber Timeline Bar -->
<div
class="group/bar relative flex h-4 w-full cursor-pointer items-center"
role="slider"
aria-label="Video Timeline Scrubber"
:aria-valuenow="currentTime"
:aria-valuemin="0"
:aria-valuemax="totalDuration"
@click="handleSeekClick"
>
<!-- Background Full Track -->
<div
class="relative h-1.5 w-full overflow-hidden rounded-full bg-zinc-800 transition-[height] group-hover/bar:h-2.5"
>
<!-- Buffer Progress -->
<div
class="absolute top-0 bottom-0 left-0 bg-zinc-700 transition-colors"
:style="{ width: `${bufferedPercent}%` }"
/>
<!-- Played Progress -->
<div
class="bg-primary absolute top-0 bottom-0 left-0 transition-colors"
:style="{ width: `${progressPercent}%` }"
/>
</div>
<!-- Scrubber Dot Handle -->
<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>
<!-- Controls Row: Play, Skip, Timestamps, Speed, Audio, Screen -->
<div class="flex items-center justify-between gap-2 pt-0.5">
<!-- Left Controls -->
<div class="flex items-center gap-1 sm:gap-2">
<!-- Play / Pause -->
<Button
variant="ghost"
size="icon"
class="size-8 text-white hover:bg-white/15"
:aria-label="isPlaying ? 'Pause' : 'Play'"
@click="togglePlay"
>
<Pause v-if="isPlaying" class="size-4.5 fill-current" />
<Play v-else class="size-4.5 fill-current" />
</Button>
<!-- 15s Rewind -->
<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)"
>
<RotateCcw class="size-4" />
</Button>
<!-- 15s Fast Forward -->
<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)"
>
<RotateCw class="size-4" />
</Button>
<!-- Volume Toggle -->
<Button
variant="ghost"
size="icon"
class="size-8 text-zinc-300 hover:bg-white/15 hover:text-white"
:aria-label="isMuted ? 'Unmute' : 'Mute'"
@click="toggleMute"
>
<VolumeX v-if="isMuted || volume === 0" class="text-destructive size-4" />
<Volume1 v-else-if="volume < 50" class="size-4" />
<Volume2 v-else class="size-4" />
</Button>
<!-- Timestamp Display -->
<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 class="text-muted-foreground">{{ formattedTotalTime }}</span>
</div>
</div>
<!-- Right Controls -->
<div class="flex items-center gap-1 sm:gap-2">
<!-- Speed Selector Button -->
<Button
variant="ghost"
size="sm"
class="h-7 px-2 font-mono text-xs font-bold text-zinc-300 hover:bg-white/15 hover:text-white"
aria-label="Cycle Playback Speed"
@click="cycleSpeed"
>
{{ playbackSpeed }}x
</Button>
<!-- Captions Subtitles Toggle -->
<Button
variant="ghost"
size="icon"
:class="[
'size-8',
showCaptions
? 'text-primary hover:bg-white/15'
: 'text-muted-foreground hover:bg-white/15 hover:text-white',
]"
:aria-label="showCaptions ? 'Hide Subtitles' : 'Show Subtitles'"
@click="showCaptions = !showCaptions"
>
<Subtitles class="size-4" />
</Button>
<!-- Fullscreen Toggle -->
<Button
variant="ghost"
size="icon"
class="size-8 text-zinc-300 hover:bg-white/15 hover:text-white"
:aria-label="isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'"
@click="isFullscreen = !isFullscreen"
>
<Minimize2 v-if="isFullscreen" class="size-4" />
<Maximize2 v-else class="size-4" />
</Button>
</div>
</div>
</div>
</div>
<!-- Lesson Key Takeaways & Downloadable Assets Row -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Key Takeaways Card -->
<Card class="shadow-xs">
<CardHeader class="pb-2.5">
<div class="flex items-center justify-between">
<CardTitle class="text-foreground flex items-center gap-1.5 text-xs font-bold tracking-tight uppercase">
<Lightbulb class="text-primary size-3.5" />
Key Takeaways
</CardTitle>
<Badge variant="outline" class="font-mono text-xs">OKLCH v4</Badge>
</div>
<CardDescription class="text-xs">
Essential architectural concepts taught in this lecture
</CardDescription>
</CardHeader>
<CardContent class="space-y-2.5 text-xs">
<div class="bg-muted/30 flex items-start gap-2.5 rounded-lg border p-2.5">
<div
class="bg-primary/10 text-primary mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full text-xs font-bold"
>
1
</div>
<div>
<span class="text-foreground font-semibold">Perceptual Uniformity:</span>
<p class="text-muted-foreground mt-0.5 leading-relaxed">
OKLCH lightness remains constant across all hues, maintaining predictable contrast ratios across
both dark and light palettes.
</p>
</div>
</div>
<div class="bg-muted/30 flex items-start gap-2.5 rounded-lg border p-2.5">
<div
class="bg-primary/10 text-primary mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full text-xs font-bold"
>
2
</div>
<div>
<span class="text-foreground font-semibold">Display P3 Gamut Clamping:</span>
<p class="text-muted-foreground mt-0.5 leading-relaxed">
Leverage vibrant P3 color gamuts on supported displays while automatically fallback-clamping for
sRGB displays.
</p>
</div>
</div>
<div class="bg-muted/30 flex items-start gap-2.5 rounded-lg border p-2.5">
<div
class="bg-primary/10 text-primary mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full text-xs font-bold"
>
3
</div>
<div>
<span class="text-foreground font-semibold">Chroma Scaling in Dark Mode:</span>
<p class="text-muted-foreground mt-0.5 leading-relaxed">
Scale down chroma by 15-20% on deep dark backgrounds to prevent ocular fatigue and visual vibration.
</p>
</div>
</div>
</CardContent>
</Card>
<!-- Downloadable Source Code & Assets Card -->
<Card class="shadow-xs">
<CardHeader class="pb-2.5">
<div class="flex items-center justify-between">
<CardTitle class="text-foreground flex items-center gap-1.5 text-xs font-bold tracking-tight uppercase">
<FileCode2 class="text-primary size-3.5" />
Lesson Assets & Code
</CardTitle>
<Badge variant="secondary" class="text-xs">3 Files</Badge>
</div>
<CardDescription class="text-xs"> Starter boilerplate and token configuration files </CardDescription>
</CardHeader>
<CardContent class="space-y-2 text-xs">
<!-- Asset 1 -->
<div
class="bg-muted/30 hover:bg-muted/50 flex items-center justify-between rounded-lg border p-2.5 transition-colors"
>
<div class="flex min-w-0 items-center gap-2.5">
<div
class="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-md font-mono text-xs font-bold"
>
TS
</div>
<div class="min-w-0">
<p class="text-foreground truncate text-xs font-semibold">tokens.config.ts</p>
<p class="text-muted-foreground font-mono text-xs">14.2 KB · Tailwind v4 Theme</p>
</div>
</div>
<Button
aria-label="Download attachment"
variant="outline"
size="sm"
class="h-7 gap-1 px-2 text-xs"
@click="handleCopyAsset('tokens.config.ts')"
>
<Check v-if="copiedAsset === 'tokens.config.ts'" class="text-success size-3" />
<Download v-else class="size-3" />
<span>{{ copiedAsset === 'tokens.config.ts' ? 'Saved' : 'Download' }}</span>
</Button>
</div>
<!-- Asset 2 -->
<div
class="bg-muted/30 hover:bg-muted/50 flex items-center justify-between rounded-lg border p-2.5 transition-colors"
>
<div class="flex min-w-0 items-center gap-2.5">
<div
class="bg-chart-2/10 text-chart-2 flex size-8 shrink-0 items-center justify-center rounded-md font-mono text-xs font-bold"
>
FIG
</div>
<div class="min-w-0">
<p class="text-foreground truncate text-xs font-semibold">palette-matrix.fig</p>
<p class="text-muted-foreground font-mono text-xs">4.8 MB · Token Library</p>
</div>
</div>
<Button
aria-label="Download attachment"
variant="outline"
size="sm"
class="h-7 gap-1 px-2 text-xs"
@click="handleCopyAsset('palette-matrix.fig')"
>
<Check v-if="copiedAsset === 'palette-matrix.fig'" class="text-success size-3" />
<Download v-else class="size-3" />
<span>{{ copiedAsset === 'palette-matrix.fig' ? 'Saved' : 'Download' }}</span>
</Button>
</div>
<!-- Asset 3 -->
<div
class="bg-muted/30 hover:bg-muted/50 flex items-center justify-between rounded-lg border p-2.5 transition-colors"
>
<div class="flex min-w-0 items-center gap-2.5">
<div
class="bg-destructive/10 text-destructive flex size-8 shrink-0 items-center justify-center rounded-md font-mono text-xs font-bold"
>
PDF
</div>
<div class="min-w-0">
<p class="text-foreground truncate text-xs font-semibold">apca-contrast-guide.pdf</p>
<p class="text-muted-foreground font-mono text-xs">1.1 MB · Cheat Sheet</p>
</div>
</div>
<Button
aria-label="Download attachment"
variant="outline"
size="sm"
class="h-7 gap-1 px-2 text-xs"
@click="handleCopyAsset('apca-contrast-guide.pdf')"
>
<Check v-if="copiedAsset === 'apca-contrast-guide.pdf'" class="text-success size-3" />
<Download v-else class="size-3" />
<span>{{ copiedAsset === 'apca-contrast-guide.pdf' ? 'Saved' : 'Download' }}</span>
</Button>
</div>
</CardContent>
</Card>
</div>
</section>
<!-- Right Column: Interactive Transcript & Notes Workspace (4 cols) -->
<aside class="flex flex-col lg:col-span-4">
<Card class="flex h-full flex-col overflow-hidden shadow-xs">
<Tabs v-model="activeTab" class="flex h-full flex-col">
<!-- Tabs Navigation Header -->
<div class="bg-muted/30 border-b p-2.5">
<TabsList class="grid h-8.5 w-full grid-cols-3">
<TabsTrigger value="transcript" class="gap-1 text-xs font-medium">
<FileText class="size-3" />
<span>Transcript</span>
</TabsTrigger>
<TabsTrigger value="notes" class="gap-1 text-xs font-medium">
<BookmarkCheck class="size-3" />
<span>Notes</span>
<Badge variant="secondary" class="ml-0.5 px-1 py-0 text-xs">
{{ notesList.length }}
</Badge>
</TabsTrigger>
<TabsTrigger value="qa" class="gap-1 text-xs font-medium">
<MessageSquareQuote class="size-3" />
<span>Q&A</span>
<Badge variant="secondary" class="ml-0.5 px-1 py-0 text-xs">
{{ questionsList.length }}
</Badge>
</TabsTrigger>
</TabsList>
</div>
<!-- TAB 1: Interactive Transcript -->
<TabsContent value="transcript" class="m-0 flex flex-1 flex-col focus-visible:outline-none">
<!-- Search Transcript Filter -->
<div class="border-b p-3">
<div class="relative">
<Search class="text-muted-foreground absolute top-2.5 left-2.5 size-3.5" />
<Input
v-model="transcriptSearch"
placeholder="Search lecture transcript..."
class="h-8 pl-8 text-xs"
/>
</div>
</div>
<!-- Transcript Segments List -->
<div class="max-h-[580px] flex-1 space-y-2.5 overflow-y-auto p-3.5">
<div
v-for="item in filteredTranscript"
:key="item.id"
:class="[
'group cursor-pointer rounded-lg border p-3 text-xs transition-colors duration-200',
currentTime >= item.start && currentTime < item.end
? 'border-primary/50 bg-primary/10 ring-primary/20 shadow-xs ring-1'
: 'border-border/70 bg-card hover:bg-muted/40',
]"
@click="seekTo(item.start)"
>
<div class="mb-1.5 flex items-center justify-between">
<div class="flex items-center gap-1.5">
<Button
variant="secondary"
size="sm"
class="h-6 gap-1 rounded-md px-1.5 font-mono text-xs font-bold tabular-nums"
@click.stop="seekTo(item.start)"
>
<Play class="size-2.5 fill-current" />
<span>{{ formatTime(item.start) }}</span>
</Button>
<span class="text-muted-foreground text-xs font-medium">{{ item.speaker }}</span>
</div>
<Badge
v-if="currentTime >= item.start && currentTime < item.end"
variant="default"
class="gap-1 px-1.5 py-0 text-xs font-semibold"
>
<span class="size-1.5 animate-pulse rounded-full bg-white" />
<span>Playing</span>
</Badge>
</div>
<p
:class="[
'leading-relaxed',
currentTime >= item.start && currentTime < item.end
? 'text-foreground font-medium'
: 'text-muted-foreground group-hover:text-foreground',
]"
>
{{ item.text }}
</p>
</div>
</div>
<!-- Transcript Footer Notice -->
<div class="bg-muted/30 text-muted-foreground border-t p-2.5 text-center text-xs">
Click any line to jump to that timestamp in the lecture.
</div>
</TabsContent>
<!-- TAB 2: Timestamped Notes Workspace -->
<TabsContent value="notes" class="m-0 flex flex-1 flex-col focus-visible:outline-none">
<!-- Note Composer -->
<div class="bg-muted/20 space-y-2 border-b p-3">
<div class="flex items-center justify-between">
<label class="text-foreground flex items-center gap-1.5 text-xs font-bold tracking-tight">
<Clock class="text-primary size-3.5" />
<span>Add Note at {{ formattedCurrentTime }}</span>
</label>
<span class="text-muted-foreground font-mono text-xs">Captures current playback time</span>
</div>
<Textarea
v-model="newNoteText"
rows="2"
class="resize-y text-xs leading-relaxed"
:placeholder="`Write your thoughts or takeaway at ${formattedCurrentTime}...`"
@keydown.enter.meta="handleAddNote"
@keydown.enter.ctrl="handleAddNote"
/>
<div class="flex items-center justify-between">
<span class="text-muted-foreground text-xs">Press ⌘+Enter to save</span>
<Button
size="sm"
class="h-7.5 gap-1 px-3 text-xs font-semibold"
:disabled="!newNoteText.trim()"
@click="handleAddNote"
>
<Plus class="size-3.5" />
<span>Save Note</span>
</Button>
</div>
</div>
<!-- Saved Notes List -->
<div class="max-h-[500px] flex-1 space-y-2.5 overflow-y-auto p-3.5">
<div
v-for="note in notesList"
:key="note.id"
class="group bg-card hover:border-border rounded-lg border p-3 text-xs shadow-xs transition-colors"
>
<div class="mb-1.5 flex items-center justify-between">
<Button
variant="outline"
size="sm"
class="text-primary h-5 gap-1 rounded px-1.5 font-mono text-xs font-bold tabular-nums"
@click="seekTo(note.timestamp)"
>
<Play class="size-2.5 fill-current" />
<span>{{ formatTime(note.timestamp) }}</span>
</Button>
<div class="flex items-center gap-1">
<span class="text-muted-foreground text-xs">{{ note.createdAt }}</span>
<Button
variant="ghost"
size="icon"
class="text-muted-foreground hover:text-destructive size-5 opacity-0 group-hover:opacity-100"
aria-label="Delete note"
@click="handleDeleteNote(note.id)"
>
<Trash2 class="size-3" />
</Button>
</div>
</div>
<p class="text-foreground leading-relaxed">{{ note.text }}</p>
</div>
<div v-if="notesList.length === 0" class="text-muted-foreground py-8 text-center text-xs">
No personal notes yet. Add your first note above!
</div>
</div>
</TabsContent>
<!-- TAB 3: Q&A Community Discussion -->
<TabsContent value="qa" class="m-0 flex flex-1 flex-col focus-visible:outline-none">
<!-- Question Composer -->
<div class="bg-muted/20 space-y-2 border-b p-3">
<div class="flex items-center justify-between">
<label class="text-foreground flex items-center gap-1.5 text-xs font-bold tracking-tight">
<MessageSquare class="text-primary size-3.5" />
<span>Ask Instructor at {{ formattedCurrentTime }}</span>
</label>
<span class="text-muted-foreground font-mono text-xs">Tagged to this video time</span>
</div>
<Input
v-model="newQuestionText"
placeholder="Ask a question about this topic..."
class="h-8 text-xs"
@keydown.enter="handleAddQuestion"
/>
<div class="flex items-center justify-end">
<Button
size="sm"
class="h-7 gap-1 px-3 text-xs font-semibold"
:disabled="!newQuestionText.trim()"
@click="handleAddQuestion"
>
<span>Post Question</span>
</Button>
</div>
</div>
<!-- Questions Thread List -->
<div class="max-h-[500px] flex-1 space-y-3 overflow-y-auto p-3.5">
<div
v-for="q in questionsList"
:key="q.id"
class="bg-card space-y-2.5 rounded-lg border p-3 text-xs shadow-xs"
>
<!-- Question Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div
class="bg-primary/10 text-primary flex size-6 items-center justify-center rounded-full text-xs font-bold"
>
{{ q.avatar }}
</div>
<div>
<span class="text-foreground font-semibold">{{ q.author }}</span>
<Button
variant="link"
size="sm"
class="text-muted-foreground ml-1.5 h-auto p-0 font-mono text-xs"
@click="seekTo(q.timestamp)"
>
[{{ formatTime(q.timestamp) }}]
</Button>
</div>
</div>
<!-- Upvote Button -->
<Button
:variant="q.hasUpvoted ? 'default' : 'outline'"
size="sm"
class="h-6 gap-1 px-1.5 text-xs"
@click="toggleUpvote(q)"
>
<ThumbsUp class="size-2.5" />
<span class="font-mono tabular-nums">{{ q.upvotes }}</span>
</Button>
</div>
<!-- Question Text -->
<p class="text-foreground leading-relaxed">{{ q.question }}</p>
<!-- Instructor Answer if available -->
<div v-if="q.answer" class="bg-muted/40 border-primary/20 space-y-1 rounded-md border p-2.5">
<div class="flex items-center gap-1.5">
<span class="text-foreground text-xs font-semibold">{{ q.answer.author }}</span>
<Badge variant="default" class="bg-success px-1 py-0 text-xs font-normal">
Instructor Verified
</Badge>
</div>
<p class="text-muted-foreground text-xs leading-relaxed">{{ q.answer.text }}</p>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</Card>
</aside>
</div>
</div>
</template>
Raw manifest:https://uipkge.dev/r/vue/lesson-video-player.json