Media

Video Playlist

A queue that sits beside your player — thumbnail rows with duration and watched progress, a now-playing marker, cancellable autoplay-next, shuffle and repeat, and a keyboard-walkable listbox.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, Check, Film, ListVideo, Play, Repeat, Repeat1, Shuffle, Zap } from "lucide-react"
import { cn } from "@/lib/utils"
import type { VideoPlaylistData, VideoPlaylistItem, VideoPlaylistRepeat } from "./video-playlist.contract"

/** Now-playing equalizer. React 19 hoisted <style> — no Tailwind config edits, no keyframe file. */
const KEYFRAMES = `@keyframes vpl-eq{0%,100%{transform:scaleY(0.3)}50%{transform:scaleY(1)}}`

const DEFAULT_AUTOPLAY_DELAY = 5
const DEFAULT_SKELETON_ROWS = 4
const MAX_SKELETON_ROWS = 12
/** How far PageUp / PageDown jump. Rows are uniform, so one constant is honest here. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/video-playlist.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "VideoPlaylist" component: the queue that
sits next to a player, never the player itself. lucide-react for the icons, zod
for the item contract, no media library and no popover library.

Contract
- The zod schema is the single source of truth and the props are z.infer of it,
  not a parallel hand-written interface:
    videoPlaylistItemSchema = { id: string, title: string,
      duration: number (seconds), thumb?: string, progress: number (0..1),
      watched: boolean }
    videoPlaylistSchema = { status: "loading" | "empty" | "error" | "ready",
      items: VideoPlaylistItem[] }
    repeat = "off" | "all" | "one"
- forwardRef<HTMLDivElement>. The root spreads every remaining native div prop
  and merges className through cn().
- Props extend that envelope with:
    player?: ReactNode        a SLOT. The component never renders a <video>,
                              never calls play(), never touches a media element.
    label?: string            default "Playlist"; names the panel and the list.
    currentId?: string | null / defaultCurrentId?: string | null
    onCurrentChange?: (id: string,
      reason: "select" | "autoplay" | "skip") => void
    playing?: boolean         default true; drives the marker only.
    endedId?: string | null   the host's `ended` event, see Behavior.
    autoplay / defaultAutoplay (true) / onAutoplayChange
    autoplayDelay?: number    seconds, default 5, clamped to >= 1 and rounded.
    shuffle / defaultShuffle / onShuffleChange, shuffleSeed?: number (default 1)
    repeat / defaultRepeat ("off") / onRepeatChange
    maxHeight?: number|string caps the scroller; omit it and the list grows.
    skeletonRows?: number     default 4, clamped 1..12.
    emptyState?: ReactNode, errorMessage?: string, onRetry?: () => void
    labels?: Partial<VideoPlaylistLabels>   every visible and spoken string.
- The four controlled/uncontrolled pairs share one tiny useControllableState
  helper: controlled when the prop is not undefined (null counts as defined).
- Nothing reads the clock. A duration is absolute seconds and progress is a
  fraction, so there is no instant for the server and the browser to disagree
  about; the only time-dependent state is the countdown, which starts as null
  and only ever ticks inside a client effect.

Behavior
- Ownership: the component owns the QUEUE — which item is current, what is next,
  what happens when one ends. It never owns playback. Everything transport-shaped
  leaves through onCurrentChange; `playing` and `endedId` come back in.
- Dedupe on the way in: a repeated id is dropped, first occurrence wins. Two rows
  sharing an id cannot be told apart by any handler here.
- A row is: index (or the now-playing marker), a poster with a duration badge and
  a watched progress bar, a two-line clamped title, and a "Watched" check or a
  "45% watched" line. A missing thumb renders the film icon with no network
  request at all; a thumb that fails renders the same icon after onError, and the
  failure is keyed by URL so a replaced poster gets a fresh attempt.
- Degenerate data is a first-class case, not a crash: a non-finite, negative or
  zero duration renders no badge and adds nothing to the header total; progress
  is clamped to 0..1 so NaN reads as 0 and 1.4 cannot overflow the track;
  `watched` wins over `progress` for the bar; a blank title falls back to
  labels.untitled so a row is never a nameless click target.
- Shuffle permutes the PLAY ORDER only — the rendered rows never reorder under
  the reader, and the footer is what reveals the difference. The permutation is
  an FNV-1a hash of the id mixed with shuffleSeed, i.e. a pure function of the
  data, because Math.random() here would hydrate a different "up next" than it
  server-rendered. Bump shuffleSeed to reshuffle.
- Next resolution: repeat "one" hands the current id back (onCurrentChange fires
  with the same id — that is the host's cue to seek to 0 and play again), repeat
  "all" wraps at the end, repeat "off" has nothing after the last item, so no
  countdown starts there at all.
- Autoplay-next: the host sets endedId when its <video> fires `ended`. An effect
  keyed on that value alone reacts once per change — a stale id that is not the
  current item is ignored, otherwise a countdown of autoplayDelay seconds starts.
  Clear endedId (or write a fresh id) once you have handled it: a constant never
  fires twice, exactly like a real player that has to play again before it can
  end again.
- The countdown is one chained setTimeout keyed on the remaining second, so every
  tick cancels the previous timer and unmounting cancels the pending one. The
  panel offers Cancel and Play now, and Escape anywhere inside the component
  cancels it (with stopPropagation, so a surrounding dialog does not close on the
  same key). Turning autoplay off cancels it as well — a panel counting down to
  something that will never happen is a lie.
- One-shot guarantee: a ref is read AND written synchronously by whoever closes
  the panel (Cancel, Play now, expiry, or a row click landing in the same tick),
  so the queue can never advance twice. Starting a countdown resets that ref.
- Focus is never dropped on <body>. The countdown panel is torn down under the
  user in four different ways, so each path checks — before the unmount — whether
  the panel really contains document.activeElement, raises a ref flag, and an
  effect after the commit moves focus to the row that is now current. The retry
  button does the same towards the panel itself, which is why the root carries
  tabIndex={-1}. A focused row that leaves the queue hands off the same way. Two
  guards stop this from becoming focus theft: the handoff only fires when focus
  actually landed on <body>, and only when the playlist owned focus recently —
  tracked by a focusin/focusout ref whose focusout keeps ownership when
  relatedTarget is null, because "focus went nowhere" is exactly the case a
  handoff exists for.
- Keyboard. The list is a listbox and a single tab stop (roving tabindex: exactly
  one row has tabIndex 0 — the focused row, else the current one, else the first):
    ArrowDown / ArrowUp    one row, clamped, no wrap
    Home / End             first / last row
    PageUp / PageDown      five rows, clamped
    Enter / Space          play the focused row
    printable characters   typeahead over the titles, 600ms buffer, a repeated
                           letter cycles matches (WAI-ARIA listbox convention)
    Escape                 cancel a running countdown
  Enter and Space are deliberately NOT handled in the list's key handler: the
  rows are real <button>s, so the browser already turns both into a click, and
  handling them again would fire onCurrentChange twice.
- Selection does not follow focus. Arrowing moves the roving anchor only;
  starting a video takes a deliberate Enter, Space or click, because "I am
  looking at this row" and "play this now" are different intents.
- ARIA: role="listbox" labelled by the header title, aria-orientation vertical;
  rows are <button role="option"> with aria-selected and a composed aria-label
  ("3. Designing tokens, 12 minutes 30 seconds, 45% watched, now playing"). The
  visual innards of a row are aria-hidden so the label is the single spoken
  truth. Autoplay and Shuffle are aria-pressed toggles; Repeat is a three-state
  cycle button, so its new mode goes through the live region instead. One
  permanently mounted polite role="status" carries the loading state, the
  countdown ("Playing next in 5 seconds: <title>. Press Escape to cancel." —
  fixed for the whole countdown, so it is announced once and not once per tick),
  a repeat change, and whatever started playing.
- The native disabled attribute is never used: nothing here is inert, and the
  controls that can vanish under the user use the handoff above instead.
- Four states are real branches, not afterthoughts: loading paints skeleton rows
  with the same number/poster/two-line rhythm (aria-hidden, aria-busy on the
  root); empty is a panel, and a "ready" queue with zero rows falls through to
  it; error shows the message plus a retry button only when onRetry is passed —
  never a dead button; ready renders the listbox and the footer.

Rendering & styling
- Semantic tokens only: a bg-card / text-card-foreground shell with a border,
  bg-muted posters and skeletons, bg-primary for the marker, the watched bar and
  the countdown bar, bg-accent for hover and for the current row,
  text-muted-foreground for secondary text, text-destructive for the error
  branch, ring-ring for every focus ring. No hex, no rgb(), no oklch() — swap the
  tokens and the playlist matches the host app, dark mode included.
- cn() merges every className; focus-visible:ring-2 ring-ring on every button,
  with ring-inset on the rows so the ring is not clipped by the scroller.
- Motion is decoration. The three-bar equalizer runs on one hoisted @keyframes
  (React 19 <style href precedence>) behind motion-reduce:[animation:none]; the
  countdown bar drains with a linear width transition behind
  motion-reduce:transition-none, as do the colour transitions. With motion off
  the marker is three static bars and the countdown still counts — nothing that
  matters is carried by animation alone.
- The queue scrolls, the chrome does not: the player slot, the header and the
  footer sit outside the scroller, so "Up next" stays visible under a long list.

Customization levers
- Density: rows are p-1.5 around an h-11 w-20 poster (16:9). Drop to h-9 w-16
  with text-xs for a rail; nothing is measured in JS, so the layout just follows.
- Sub-blocks are independent: delete the footer to lose "Up next" without losing
  autoplay; delete the toolbar and drive autoplay / shuffle / repeat from your
  own chrome, since all three are controlled props; or drop the player slot and
  render the playlist beside a player that lives elsewhere on the page.
- Countdown: autoplayDelay is the whole knob (5s is the familiar default). Swap
  the draining bar for a circular ring, or remove the bar and keep the number.
- Ordering: shuffleSeed is the reshuffle handle — pass a seed your host generates
  once per session for a per-session order. Want shuffle to reorder the visible
  rows too? Render the computed play order instead of items; nothing else in the
  logic cares.
- Tokens: recolour the watched bar to var(--chart-1) and the marker to
  var(--chart-2) when the playlist has to match a chart, and keep bg-primary for
  the countdown so the one urgent thing stays the accent.
- i18n: labels is a complete Partial map — around twenty strings, including the
  "video" / "videos" pair and the untitled fallback. The clock format (m:ss and
  h:mm:ss) is deliberately locale-independent, while durations are spoken as
  words rather than as a clock, so a screen reader does not read 12:30 as half
  past twelve.
- Data: the contract is the seam. Add fields (channel, uploadedAt, a badge) to
  the item schema and render them on the second line of the text column — the
  queue logic only ever reads id, title, duration, progress and watched.

Concepts

  • Queue, not playback — the component decides what plays next and hands the id out; the <video> decides how. That split is what lets the same playlist sit next to a self-hosted player, a YouTube iframe or a native app shell, and it is why player is a slot rather than a src prop.
  • Cancellable autoplay — auto-advance without a visible, cancellable, keyboard-reachable countdown is a dark pattern; the panel spells out what is coming, in how many seconds, and gives you Escape, Cancel and Play now. Turning autoplay off kills the countdown with it.
  • Deterministic shuffle — the play order is a hash of the ids, not Math.random(), so the server and the browser paint the same "Up next" and hydration stays quiet. shuffleSeed is the reshuffle handle, and the visible rows never reorder — only the arrow into the next one moves.
  • Selection does not follow focus — arrowing walks the roving tabindex without starting anything; playback needs a deliberate Enter, Space or click. Browsing a queue and committing to a video are different intents, and a listbox that plays whatever you arrow past makes the keyboard unusable.
  • Focus handoff on unmount — the countdown panel disappears under the user four different ways (Cancel, Play now, expiry, a data change), and a control that vanishes while focused drops focus on <body>. Every path checks whether it really held focus, raises a ref flag, and an effect after the commit puts the caret on the row that is now playing.
  • Watched wins over progress — a finished video reads as a full bar and a check even when the last reported position was 0.97, while a progress of 1.4 or NaN clamps instead of overflowing. The contract describes what an API should send; the component survives what it does send.

On This Page