diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82e9687..8e10169 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,11 @@ on: pull_request: branches: [main] +# Cancel superseded runs on the same ref to save CI minutes. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: runs-on: ubuntu-latest @@ -13,18 +18,30 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup pnpm - uses: pnpm/action-setup@v4 + # Installs Node and pnpm at the exact versions pinned in mise.toml. + - name: Setup toolchain (mise) + uses: jdx/mise-action@v2 + with: + cache: true + + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_ENV" - - name: Setup Node - uses: actions/setup-node@v4 + - name: Cache pnpm store + uses: actions/cache@v4 with: - node-version: 24 - cache: pnpm + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Format check + run: pnpm format:check + - name: Typecheck run: pnpm typecheck diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1023f2a..338823a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,13 +17,18 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 + # Installs Node and pnpm at the exact versions pinned in mise.toml. + - name: Setup toolchain (mise) + uses: jdx/mise-action@v2 with: - node-version: 24 - cache: pnpm - registry-url: https://registry.npmjs.org + cache: true + + # Configure npm auth for changesets publish. + - name: Configure npm registry + run: | + echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" > ~/.npmrc + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: pnpm install --frozen-lockfile diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..f33ebba --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +# Generated / vendored — never reformat. +dist +node_modules +pnpm-lock.yaml +CHANGELOG.md diff --git a/README.md b/README.md index bf63aba..0e14320 100644 --- a/README.md +++ b/README.md @@ -45,18 +45,18 @@ The quickest knob is the `accentColor` prop, which drives the scrubber fill, act For deeper control, every visual is driven by CSS custom properties on the `.kino` root. Override them in your own stylesheet, or pass a `theme` object of property/value pairs to set them inline. -| Custom property | Default | Role | -| --- | --- | --- | -| `--kino-accent` | `oklch(50.8% 0.118 165.612)` | Accent color (progress, active items, ranges) | -| `--kino-radius` | `12px` | Corner radius of glass surfaces | -| `--kino-surface` | `color-mix(in oklab, black 55%, transparent)` | Glass surface fill | -| `--kino-surface-strong` | `color-mix(in oklab, black 70%, transparent)` | Stronger surface (idle play button) | -| `--kino-border` | `color-mix(in oklab, white 14%, transparent)` | Hairline borders | -| `--kino-text` | `oklch(98% 0 0)` | Primary text and icons | -| `--kino-text-dim` | `color-mix(in oklab, white 65%, transparent)` | Secondary text (timecode) | -| `--kino-blur` | `18px` | Backdrop blur radius | -| `--kino-shadow` | `0 8px 40px rgba(0, 0, 0, 0.45)` | Surface drop shadow | -| `--kino-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | Shared transition easing | +| Custom property | Default | Role | +| ----------------------- | --------------------------------------------- | --------------------------------------------- | +| `--kino-accent` | `oklch(50.8% 0.118 165.612)` | Accent color (progress, active items, ranges) | +| `--kino-radius` | `12px` | Corner radius of glass surfaces | +| `--kino-surface` | `color-mix(in oklab, black 55%, transparent)` | Glass surface fill | +| `--kino-surface-strong` | `color-mix(in oklab, black 70%, transparent)` | Stronger surface (idle play button) | +| `--kino-border` | `color-mix(in oklab, white 14%, transparent)` | Hairline borders | +| `--kino-text` | `oklch(98% 0 0)` | Primary text and icons | +| `--kino-text-dim` | `color-mix(in oklab, white 65%, transparent)` | Secondary text (timecode) | +| `--kino-blur` | `18px` | Backdrop blur radius | +| `--kino-shadow` | `0 8px 40px rgba(0, 0, 0, 0.45)` | Surface drop shadow | +| `--kino-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | Shared transition easing | ```css .kino { @@ -70,15 +70,15 @@ For deeper control, every visual is driven by CSS custom properties on the `.kin The player is keyboard-first. Shortcuts are ignored while a text input, textarea, select, or contenteditable element is focused, and modifier-key combinations (Ctrl/Cmd/Alt) are passed through. -| Key | Action | -| --- | --- | -| `Space` / `K` | Play / pause | -| `<` / `>` | Decrease / increase playback rate (0.25 step) | -| `M` | Toggle mute | -| `C` | Toggle captions | -| `S` | Open the speed menu | -| `F` | Toggle fullscreen | -| `0`-`9` | Seek to 0%-90% of the duration | +| Key | Action | +| ------------- | --------------------------------------------- | +| `Space` / `K` | Play / pause | +| `<` / `>` | Decrease / increase playback rate (0.25 step) | +| `M` | Toggle mute | +| `C` | Toggle captions | +| `S` | Open the speed menu | +| `F` | Toggle fullscreen | +| `0`-`9` | Seek to 0%-90% of the duration | ## Capability gating diff --git a/demo/file-provider.ts b/demo/file-provider.ts index 39ab0ed..66dc29e 100644 --- a/demo/file-provider.ts +++ b/demo/file-provider.ts @@ -16,7 +16,9 @@ export function createFileProvider(src: string, poster?: string): Provider { capabilities: { canSetQuality: false, hasStoryboard: false, - canPiP: typeof document !== "undefined" && "pictureInPictureEnabled" in document, + canPiP: + typeof document !== "undefined" && + "pictureInPictureEnabled" in document, canFullscreen: true, canSetRate: true, hasTextTracks: false, diff --git a/docs/superpowers/plans/2026-06-25-kino-video-player.md b/docs/superpowers/plans/2026-06-25-kino-video-player.md index 560944e..0f39bff 100644 --- a/docs/superpowers/plans/2026-06-25-kino-video-player.md +++ b/docs/superpowers/plans/2026-06-25-kino-video-player.md @@ -25,15 +25,15 @@ These types are defined in Task 2 (`src/core/types.ts`) and referenced verbatim ```ts export type QualityLevel = { - id: string // rendition id from the engine - height: number // e.g. 1080 - bitrate: number // bits/sec - selected: boolean // currently the pinned manual selection + id: string // rendition id from the engine + height: number // e.g. 1080 + bitrate: number // bits/sec + selected: boolean // currently the pinned manual selection } export type TextTrackInfo = { id: string - kind: string // "subtitles" | "captions" | ... + kind: string // "subtitles" | "captions" | ... label: string lang: string mode: "showing" | "hidden" | "disabled" @@ -43,7 +43,7 @@ export type Capabilities = { canSetQuality: boolean hasStoryboard: boolean canPiP: boolean - canFullscreen: boolean // custom-chrome fullscreen (false on iPhone) + canFullscreen: boolean // custom-chrome fullscreen (false on iPhone) canSetRate: boolean hasTextTracks: boolean } @@ -54,11 +54,11 @@ export type MediaState = { paused: boolean currentTime: number duration: number - buffered: Array<[number, number]> // [start, end] seconds + buffered: Array<[number, number]> // [start, end] seconds rate: number - volume: number // 0..1 + volume: number // 0..1 muted: boolean - readyState: number // HTMLMediaElement.readyState + readyState: number // HTMLMediaElement.readyState seeking: boolean ended: boolean error: MediaError | null @@ -138,11 +138,13 @@ kino/ (repo root /Users/karn/code/karnstack/kin ### Task 1: Repo scaffold, tooling, and theme stylesheet **Files:** + - Create: `package.json`, `tsconfig.json`, `tsup.config.ts`, `vitest.config.ts`, `eslint.config.js`, `.prettierrc`, `.gitignore`, `LICENSE`, `README.md` - Create: `src/index.ts`, `src/mux.ts`, `src/styles/kino.css` - Create: `src/util/format-time.ts`, `src/util/format-time.test.ts` **Interfaces:** + - Produces: a buildable/testable package; `formatTime(seconds: number): string`. - [ ] **Step 1: Create the repo and pnpm package** @@ -290,9 +292,16 @@ import "@testing-library/jest-dom/vitest" background: black; overflow: hidden; } -.kino * { box-sizing: border-box; } +.kino * { + box-sizing: border-box; +} .kino mux-video, -.kino video { position: absolute; inset: 0; width: 100%; height: 100%; } +.kino video { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} .kino-glass { background: var(--kino-surface); backdrop-filter: blur(var(--kino-blur)); @@ -302,7 +311,11 @@ import "@testing-library/jest-dom/vitest" box-shadow: var(--kino-shadow); } @media (prefers-reduced-motion: reduce) { - .kino *, .kino *::before, .kino *::after { transition-duration: 0.01ms !important; } + .kino *, + .kino *::before, + .kino *::after { + transition-duration: 0.01ms !important; + } } ``` @@ -363,10 +376,12 @@ git commit -m "chore: scaffold @karnstack/kino package, tooling, theme css" ### Task 2: Core types and the player store **Files:** + - Create: `src/core/types.ts` (the Shared Type Contract verbatim) - Create: `src/core/fake-provider.ts`, `src/core/store.tsx`, `src/core/store.test.tsx` **Interfaces:** + - Consumes: nothing. - Produces: - `PlayerContext` (React context holding a `Provider`) @@ -382,17 +397,35 @@ git commit -m "chore: scaffold @karnstack/kino package, tooling, theme css" import type { MediaState, Provider, PlayerActions } from "./types" const DEFAULT_CAPS = { - canSetQuality: true, hasStoryboard: false, canPiP: true, - canFullscreen: true, canSetRate: true, hasTextTracks: false, + canSetQuality: true, + hasStoryboard: false, + canPiP: true, + canFullscreen: true, + canSetRate: true, + hasTextTracks: false, } export function defaultState(): MediaState { return { - paused: true, currentTime: 0, duration: 0, buffered: [], - rate: 1, volume: 1, muted: false, readyState: 0, seeking: false, - ended: false, error: null, qualities: [], activeQualityId: "auto", - textTracks: [], activeTextTrackId: null, fullscreen: false, pip: false, - storyboard: null, capabilities: { ...DEFAULT_CAPS }, + paused: true, + currentTime: 0, + duration: 0, + buffered: [], + rate: 1, + volume: 1, + muted: false, + readyState: 0, + seeking: false, + ended: false, + error: null, + qualities: [], + activeQualityId: "auto", + textTracks: [], + activeTextTrackId: null, + fullscreen: false, + pip: false, + storyboard: null, + capabilities: { ...DEFAULT_CAPS }, } } @@ -421,7 +454,10 @@ export function createFakeProvider(initial?: Partial) { const provider: Provider = { mount: () => {}, getState: () => state, - subscribe: (l) => { listeners.add(l); return () => listeners.delete(l) }, + subscribe: (l) => { + listeners.add(l) + return () => listeners.delete(l) + }, actions, destroy: () => listeners.clear(), } @@ -452,7 +488,7 @@ test("useMediaSelector re-renders only when the selected slice changes", () => { render( - + , ) const before = renders act(() => provider.set({ currentTime: 5 })) // unrelated slice @@ -469,8 +505,9 @@ test("usePlayer exposes actions that drive state", () => { } render( - + +