← ui

playable

lib

anything with a clock that plays: its length and its chapters, without React

npx shadcn add @ag/playable
Already installed? Preview an update
npx shadcn add @ag/playable --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-11-05
  • Changed (breaking): clip is playable (swift-utils' words: a playable is anything with a clock that plays, a clip a part of one file's time): lib/clip's Clip is lib/playable's Playable, and Span lives in its own item, span. Every driver takes playable= where it took clip= (Playback, Player, DisplayPlayback), and every timeline that made one names it .playable (sessionTimeline, sceneClock, codeTimeline, browserTimeline, walkthroughTimeline). Migration: shadcn add @ag/playable, delete clip.ts, import Playable and the outline from lib/playable and Span from lib/span, rename clip= and .clip.
Updated 2026-11-05 · release notes

shelf motion

uses span

used by video, walkthrough, walkthrough-session, glass-display, terminal-session, code-session, playback, player, timeline, macos-session, browser, telegram-chat, telegram-summary

chapters and an instant

  • 0–678 ms awake grant
  • 678–2118 ms awake grant --remove
  • progressAt(playable, 1000) = 0.472

the demo above, verbatim

playable.demo.tsx
import { Sample } from "@/app/samples"
import { progressAt, spans } from "@/registry/base-nova/lib/playable"
import {
  parseSession,
  sessionTimeline,
} from "@/registry/base-nova/lib/terminal-session"

// #region playable
const PLAYABLE = sessionTimeline(
  parseSession(`$ awake grant
~ macOS asks once, natively

@600 $ awake grant --remove
~ takes it back`),
).playable
// #endregion

/** A terminal session's playable: its length, a chapter per command, and an instant
 *  as the progress its component draws. */
export default function PlayableDemo() {
  return (
    <Sample
      name="playable"
      title="a session's playable"
      description="chapters and an instant"
      with="playable"
    >
      <ul className="space-y-1 font-mono text-xs">
        {spans(PLAYABLE).map((c) => (
          <li key={c.start}>
            <span className="text-muted-foreground tabular-nums">
              {c.start}–{c.end} ms
            </span>{" "}
            {c.title}
          </li>
        ))}
        <li className="text-muted-foreground">
          progressAt(playable, 1000) = {progressAt(PLAYABLE, 1000).toFixed(3)}
        </li>
      </ul>
    </Sample>
  )
}

the source, every file an install writes

playable.ts
// A PLAYABLE: anything with a clock that plays (swift-utils' WORDS.md: a file, a clip,
// a sequence, a scene, or content an app draws itself), told as the facts a driver
// moves through it by: how long it runs, how it is cut, and (when it is told in
// frames) its rate. The content's own lib makes its playable (macos-session's scene
// clock, terminal-session's session timeline); a driver moves through it (the player
// by the clock, a scroll stage by scroll, a still by one fixed instant); the component
// draws the frame at a progress. One contract, so any content plays in any driver.
// Framework-free.
//
// The words, each with one meaning: an INSTANT is a point in time (ms); a SPAN is a
// stretch `{from, to}` (ms, lib/span); the OUTLINE is how the playable is cut, coarse
// to fine; a LEVEL is one depth of it (chapter, scene, shot); a DIVISION is one item
// of a level, written the concise way, a start and a title, running to the next
// division's start. Chapters are the outline's first level. Nesting is never stored:
// a scene's chapter is the chapter containing its start, so a level that cuts across
// another breaks nothing.

import type { Span } from "@/registry/base-nova/lib/span"

/** One item of a level: where it starts (ms) and its title. It runs to the next
 *  division's start, the last to the end. */
export interface Division {
  start: number
  title: string
  /** The title is code, as typed (a terminal's command): drawn in mono. */
  code?: boolean
}

/** A division of the outline's first level. */
export type Chapter = Division

/** One depth of the outline: its name (`chapter`, `scene`, `shot`) and its divisions,
 *  ordered by start. */
export interface Level {
  name: string
  divisions: Division[]
}

/** How a playable is cut, coarse to fine. */
export type Outline = Level[]

export interface Playable {
  /** ms */
  duration: number
  /** The outline's first level, the one a bar divides into. */
  chapters: Chapter[]
  /** Finer levels below the chapters, coarse to fine (scenes, shots), when known. */
  levels?: Level[]
  /** Frames per second, for a playable told in frames (two copies of a film compared
   *  frame by frame): its timebase steps by them. */
  fps?: number
}

/** The playable's whole outline: its chapters as the first level, then any finer
 *  ones. A playable with no chapters is one level of one division, so every outline
 *  has a first level to divide a bar into. */
export function outlineOf(playable: Playable): Outline {
  const chapters: Level = {
    name: "chapter",
    divisions: playable.chapters.length
      ? playable.chapters
      : [{ start: 0, title: "" }],
  }
  return [chapters, ...(playable.levels ?? [])]
}

/** An instant of the playable (ms) as the 0..1 progress its component draws. */
export function progressAt(playable: Playable, ms: number): number {
  return Math.min(1, Math.max(0, ms / playable.duration))
}

/** Chapters `first` through `last` as one span: from the first's start to the last's
 *  end. An act that tells messages 4 and 5 of a chat is `chapterSpan(playable, 4, 5)`. */
export function chapterSpan(
  playable: Playable,
  first: number,
  last = first,
): Span {
  const all = spans(playable)
  const a = all[first]
  const b = all[last]
  if (!a || !b || last < first)
    throw new Error(
      `chapterSpan: chapters ${first}..${last} outside 0..${all.length - 1}`,
    )
  return { from: a.start, to: b.end }
}

/** A local 0..1 across a span, as the progress of the whole playable: a scrubbed card
 *  that tells only part of its playable. */
export function progressWithin(
  playable: Playable,
  span: Span,
  local: number,
): number {
  const t = Math.min(1, Math.max(0, local))
  return progressAt(playable, span.from + (span.to - span.from) * t)
}

/** A level's divisions with their ends, the last running to `duration`. */
export function divisionSpans(
  level: Level,
  duration: number,
): (Division & { end: number })[] {
  const list = level.divisions.length
    ? level.divisions
    : [{ start: 0, title: "" }]
  return list.map((d, i) => ({ ...d, end: list[i + 1]?.start ?? duration }))
}

/** The division of a level containing `ms`. */
export function divisionAt(
  level: Level,
  duration: number,
  ms: number,
): (Division & { end: number }) | undefined {
  const all = divisionSpans(level, duration)
  return all.findLast((d) => ms >= d.start) ?? all[0]
}

/** The chapters with their ends; a playable without chapters is one whole span. */
export function spans(playable: Playable): (Chapter & { end: number })[] {
  return divisionSpans(
    { name: "chapter", divisions: playable.chapters },
    playable.duration,
  )
}