Showing preview only (462K chars total). Download the full file or copy to clipboard to get everything.
Repository: liqvidjs/player
Branch: main
Commit: 771ca4eba36e
Files: 258
Total size: 404.6 KB
Directory structure:
gitextract_isymmvu1/
├── .gitignore
├── LICENSE
├── README.md
├── biome.json
├── build.mjs
├── package.json
├── packages/
│ ├── captioning/
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── index.ts
│ │ │ ├── transcription.ts
│ │ │ └── webvtt.ts
│ │ └── tsconfig.json
│ ├── cli/
│ │ ├── liqvid-cli.mjs
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── index.mts
│ │ │ └── tasks/
│ │ │ ├── audio.mts
│ │ │ ├── build.mts
│ │ │ ├── config.mts
│ │ │ ├── index.mts
│ │ │ ├── load-sync.cts
│ │ │ ├── render.mts
│ │ │ ├── serve.mts
│ │ │ └── thumbs.mts
│ │ └── tsconfig.json
│ ├── diff/
│ │ ├── CHANGELOG.md
│ │ ├── README.md
│ │ ├── jest.config.js
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── apply.ts
│ │ │ ├── builders.ts
│ │ │ ├── compute.ts
│ │ │ ├── index.ts
│ │ │ ├── merge.ts
│ │ │ ├── runes.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── tests/
│ │ │ └── suite.test.ts
│ │ └── tsconfig.json
│ ├── duration/
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── gsap/
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── host/
│ │ ├── README.md
│ │ ├── lv-host.js
│ │ └── package.json
│ ├── hydration/
│ │ ├── CHANGELOG.md
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── HydrateElement.tsx
│ │ │ ├── HydrateOnClient.tsx
│ │ │ ├── HydrateVariants.tsx
│ │ │ ├── SneakyScript.tsx
│ │ │ ├── golf.ts
│ │ │ ├── index.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ └── tsconfig.json
│ ├── katex/
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── rollup.config.js
│ │ ├── src/
│ │ │ ├── RenderGroup.ts
│ │ │ ├── fancy.tsx
│ │ │ ├── index.tsx
│ │ │ ├── loading.ts
│ │ │ └── plain.tsx
│ │ └── tsconfig.json
│ ├── keymap/
│ │ ├── CHANGELOG.md
│ │ ├── README.md
│ │ ├── jest.config.js
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── index.ts
│ │ │ ├── mixedCaseVals.ts
│ │ │ └── react.ts
│ │ ├── tests/
│ │ │ └── keymap.test.ts
│ │ └── tsconfig.json
│ ├── magic/
│ │ ├── README.md
│ │ ├── jest.config.js
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── default-assets.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── tests/
│ │ │ └── magic.test.ts
│ │ └── tsconfig.json
│ ├── main/
│ │ ├── CHANGELOG.md
│ │ ├── DEVELOPMENT.md
│ │ ├── README.md
│ │ ├── e2e/
│ │ │ ├── app/
│ │ │ │ ├── package.json
│ │ │ │ ├── src/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── static/
│ │ │ │ │ ├── index.html
│ │ │ │ │ └── style.css
│ │ │ │ ├── tsconfig.json
│ │ │ │ └── webpack.config.js
│ │ │ └── tests/
│ │ │ └── Media.spec.tsx
│ │ ├── jest.config.js
│ │ ├── package.json
│ │ ├── playwright.config.ts
│ │ ├── rollup.config.js
│ │ ├── src/
│ │ │ ├── Audio.tsx
│ │ │ ├── CaptionsDisplay.tsx
│ │ │ ├── Controls.tsx
│ │ │ ├── IdMap.tsx
│ │ │ ├── Media.ts
│ │ │ ├── Player.tsx
│ │ │ ├── Video.tsx
│ │ │ ├── controls/
│ │ │ │ ├── Captions.tsx
│ │ │ │ ├── FullScreen.tsx
│ │ │ │ ├── PlayPause.tsx
│ │ │ │ ├── ScrubberBar.tsx
│ │ │ │ ├── Settings.tsx
│ │ │ │ ├── ThumbnailBox.tsx
│ │ │ │ ├── TimeDisplay.tsx
│ │ │ │ └── Volume.tsx
│ │ │ ├── fake-fullscreen.ts
│ │ │ ├── hooks.ts
│ │ │ ├── i18n.ts
│ │ │ ├── index.ts
│ │ │ ├── playback.ts
│ │ │ ├── polyfills.ts
│ │ │ ├── script.ts
│ │ │ ├── utils/
│ │ │ │ ├── authoring.ts
│ │ │ │ ├── dom.ts
│ │ │ │ ├── interactivity.ts
│ │ │ │ ├── media.ts
│ │ │ │ ├── mobile.ts
│ │ │ │ └── rsc.ts
│ │ │ └── utils.ts
│ │ ├── styl/
│ │ │ ├── controls/
│ │ │ │ ├── captions.styl
│ │ │ │ ├── scrubber.styl
│ │ │ │ ├── settings.styl
│ │ │ │ ├── thumbs.styl
│ │ │ │ ├── time.styl
│ │ │ │ └── volume.styl
│ │ │ ├── liqvid.styl
│ │ │ └── mobile.styl
│ │ ├── tests/
│ │ │ ├── DocumentTimeline.mock
│ │ │ ├── IdMap.test.tsx
│ │ │ ├── Player.test.tsx
│ │ │ ├── controls/
│ │ │ │ ├── PlayPause.test.tsx
│ │ │ │ ├── ScrubberBar.test.tsx
│ │ │ │ ├── TimeDisplay.test.tsx
│ │ │ │ ├── Volume.test.tsx
│ │ │ │ └── __snapshots__/
│ │ │ │ ├── PlayPause.test.tsx.snap
│ │ │ │ └── Volume.test.tsx.snap
│ │ │ ├── hooks.test.tsx
│ │ │ ├── matchMedia.mock
│ │ │ └── script.test.ts
│ │ └── tsconfig.json
│ ├── mathjax/
│ │ ├── .gitignore
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── RenderGroup.ts
│ │ │ ├── fancy.tsx
│ │ │ ├── index.ts
│ │ │ ├── loading.ts
│ │ │ └── plain.tsx
│ │ ├── test/
│ │ │ └── index.js
│ │ └── tsconfig.json
│ ├── playback/
│ │ ├── CHANGELOG.md
│ │ ├── README.md
│ │ ├── jest.config.js
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── animation.ts
│ │ │ ├── core.ts
│ │ │ ├── index.ts
│ │ │ └── react.ts
│ │ ├── tests/
│ │ │ └── core.test.ts
│ │ └── tsconfig.json
│ ├── player/
│ │ └── package.json
│ ├── polyfills/
│ │ ├── package.json
│ │ └── src/
│ │ ├── polyfills.ts
│ │ └── waapi.js
│ ├── prompt/
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── Cue.tsx
│ │ │ ├── Prompt.tsx
│ │ │ └── index.ts
│ │ ├── style.css
│ │ ├── style.styl
│ │ └── tsconfig.json
│ ├── react/
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── index.ts
│ │ │ └── three.tsx
│ │ └── tsconfig.json
│ ├── react-three/
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ └── index.tsx
│ │ └── tsconfig.json
│ ├── recording/
│ │ ├── CHANGELOG.md
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── Control.tsx
│ │ │ ├── RecordingManager.ts
│ │ │ ├── RecordingRow.tsx
│ │ │ ├── index.ts
│ │ │ ├── recorder.ts
│ │ │ ├── recorders/
│ │ │ │ ├── audio-recording.tsx
│ │ │ │ ├── marker-recording.tsx
│ │ │ │ ├── replay-data-recorder.ts
│ │ │ │ └── video-recording.tsx
│ │ │ └── types.ts
│ │ ├── styl/
│ │ │ └── style.styl
│ │ └── tsconfig.json
│ ├── renderer/
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── index.mts
│ │ │ ├── tasks/
│ │ │ │ ├── convert.mts
│ │ │ │ ├── join.mts
│ │ │ │ ├── solidify.mts
│ │ │ │ └── thumbs.mts
│ │ │ ├── types.ts
│ │ │ └── utils/
│ │ │ ├── binaries.mts
│ │ │ ├── capture.mts
│ │ │ ├── concurrency.mts
│ │ │ ├── connect.mts
│ │ │ ├── pool.mts
│ │ │ └── stitch.mts
│ │ └── tsconfig.json
│ ├── server/
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── ssr/
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── index.ts
│ │ │ └── react.ts
│ │ └── tsconfig.json
│ ├── utils/
│ │ ├── CHANGELOG.md
│ │ ├── README.md
│ │ ├── jest.config.js
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── animation.ts
│ │ │ ├── interaction.ts
│ │ │ ├── interactivity.ts
│ │ │ ├── json.ts
│ │ │ ├── misc.ts
│ │ │ ├── react.ts
│ │ │ ├── replay-data.ts
│ │ │ ├── ssr.ts
│ │ │ ├── svg.ts
│ │ │ ├── time.ts
│ │ │ └── types.ts
│ │ ├── tests/
│ │ │ ├── animation.test.ts
│ │ │ ├── json.test.ts
│ │ │ ├── misc.test.ts
│ │ │ ├── react.test.tsx
│ │ │ └── time.test.ts
│ │ └── tsconfig.json
│ └── xyjax/
│ ├── README.md
│ ├── package.json
│ ├── rollup.config.js
│ ├── src/
│ │ └── index.ts
│ └── tsconfig.json
├── pnpm-workspace.yaml
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
packages/**/LICENSE
# Node
node_modules
dist
coverage
tsconfig.tsbuildinfo
*.log
# Configuration
.env
# Editors
*.code-*
*.sublime-*
# Generated
bundle.js
# Operating system
.DS_Store
# static files
*.mp4
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) Yuri Sulyma
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================
FILE: README.md
================================================
# Liqvid
[Liqvid](https://liqvidjs.org/) is a library for creating **interactive** videos in React.
## Links
[Documentation](https://liqvidjs.org/docs/)
[Discord](https://discord.gg/u8Qab99zHx)
## Repository structure
This is a monorepo. Here is what the various packages do:
### Frontend Core
* `main`
Provides the main `liqvid` package.
* `host`
Script for pages hosting Liqvid videos; currently just handles [fake fullscreen](https://liqvidjs.org/docs/guide/mobile#fake-fullscreen)
* `keymap`
Provides the [`Keymap`](https://liqvidjs.org/docs/reference/Keymap) class
* `playback`
Provides the [`Playback`](https://liqvidjs.org/docs/reference/Playback) class
* `polyfills`
Polyfills for Liqvid videos; currently just handles [Web Animations](https://liqvidjs.org/docs/guide/mobile/#web-animations)
* `utils`
Provides the various helper functions in [`Utils`](https://liqvidjs.org/docs/reference/Utils/animation)
### Backend Tools
* `cli`
The Liqvid [CLI tool](https://liqvidjs.org/docs/cli/tool)
* `magic`
Provides wacky[resource macro](https://liqvidjs.org/docs/cli/macros) syntax
* `renderer`
Handles the [`audio`](https://liqvidjs.org/docs/cli/audio), [`build`](https://liqvidjs.org/docs/cli/build), [`render`](https://liqvidjs.org/docs/cli/render), and [`thumbs`](https://liqvidjs.org/docs/cli/thumbs) CLI commands
* `serve`
Development server; provides the [`serve`](https://liqvidjs.org/docs/cli/tool) CLI command
### Integrations
* `katex`
Provides [KaTeX integration](https://liqvidjs.org/docs/integrations/katex)
* `react-three`
Provides [React Three Fiber](https://liqvidjs.org/docs/integrations/three) integration
### In-development
* `captioning`
Captions editor
* `gsap`
[GSAP](https://greensock.com/gsap/) integration (maybe already works???)
* `i18n`
Internationalization utilities
* `player`
New Web Components-based `<Player>`
* `mathjax`
[MathJax](https://www.mathjax.org/) integration
* `react`
Probably for when Liqvid goes to Web Components (v3)
* `xyjax`
[XyJax](https://github.com/sonoisa/XyJax-v3/) integration
================================================
FILE: biome.json
================================================
{
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"assist": {
"actions": {
"source": {
"organizeImports": {
"level": "on",
"options": {
"groups": [
":NODE:",
":BLANK_LINE:",
":PACKAGE:",
":BLANK_LINE:",
":ALIAS:",
":BLANK_LINE:",
["../**", "!**/*.css", "!**/*.json", "!**/*.scss"],
":BLANK_LINE:",
["./**", "!**/*.css", "!**/*.json", "!**/*.scss"],
":BLANK_LINE:",
["**/*.css", "**/*.scss"],
":BLANK_LINE:",
"**/*.json"
]
}
},
"useSortedAttributes": "on",
"useSortedKeys": "on"
}
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"files": {
"ignoreUnknown": false,
"includes": [
"**",
"!build",
"!**/dist",
"!**/package.json",
"!**/package-lock.json",
"!**/node_modules",
"!public/vendor/mathjax/*",
"!**/recordings/*.json",
"!**/recordings.json",
"!**/bundle.js"
]
},
"formatter": {
"attributePosition": "auto",
"bracketSpacing": true,
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 80,
"useEditorconfig": true
},
"javascript": {
"formatter": {
"arrowParentheses": "always",
"attributePosition": "auto",
"bracketSameLine": false,
"bracketSpacing": true,
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"quoteStyle": "double",
"semicolons": "always",
"trailingCommas": "all"
}
},
"linter": {
"enabled": true,
"rules": {
"correctness": {
"useExhaustiveDependencies": "error",
"useUniqueElementIds": "off"
},
"nursery": {
"useSortedClasses": {
"fix": "safe",
"level": "info",
"options": {
"functions": ["clsx", "cva", "tw", "classNames"]
}
}
},
"recommended": true,
"style": {
"useTemplate": "off"
},
"suspicious": {
"noUnknownAtRules": "off"
}
}
},
"vcs": {
"clientKind": "git",
"enabled": false,
"useIgnoreFile": true
}
}
================================================
FILE: build.mjs
================================================
/* Horrifying fixer-upper for ESM imports */
import * as fs from "fs";
import {existsSync, promises as fsp, readFileSync} from "fs";
import * as path from "path";
const DIST = path.join(process.cwd(), "dist");
const NODE_MODULES = path.join(process.cwd(), "node_modules");
build();
async function build() {
for (const type of ["esm", "cjs"]) {
const dir = path.join(DIST, type);
const extn = type === "esm" ? "mjs" : "cjs";
// rename files first
await walkDir(dir, async (filename) => {
if (!filename.endsWith(".js")) return;
await renameExtension(filename, extn);
});
// now fix imports
await walkDir(dir, async (filename) => {
if (!filename.endsWith(`.${extn}`)) return;
await fixImports(filename, type);
});
}
}
/**
* Recursively walk a directory
* @param {string} dirname Name of directory to walk
* @param {(filename: string) => Promise<void>} callback Callback
*/
async function walkDir(dirname, callback) {
const files = (await fsp.readdir(dirname)).map((filename) =>
path.join(dirname, filename),
);
/* first rename all files */
await Promise.all(
files.map(async (filename) => {
const stat = await fsp.stat(filename);
if (stat.isDirectory()) {
return walkDir(filename, callback);
}
await callback(filename);
}),
);
}
/**
* Add extensions to relative imports
* @param {string} filename File to operate on.
* @param {"esm" | "cjs"} type Javascript module system in use.
*/
async function fixImports(filename, type = "esm") {
let content = await fsp.readFile(filename, "utf8");
const regex =
type === "esm"
? /^((?:ex|im)port .+? from\s+)(["'])(.+?)(\2;?)$/gm
: /(require\()(['"])(.+?)(\2\))/gm;
content = content.replaceAll(regex, (match, head, q, name, tail) => {
// already has extension
if (name.match(/\.[cm]?js$/)) {
return match;
}
// relative imports
if (name.startsWith(".")) {
// figure out which file it's referring to
const target = findExtension(path.dirname(filename), name);
return head + q + target + tail;
} else {
}
return match;
});
await fsp.writeFile(filename, content);
}
/**
* Find extension
*/
function findExtension(pathname, relative) {
const filename = path.resolve(pathname, relative);
for (const extn of ["mjs", "js", "cjs"]) {
const full = filename + "." + extn;
if (existsSync(full)) {
let rewrite = path.relative(pathname, full);
if (!rewrite.startsWith(".")) {
rewrite = "./" + rewrite;
}
return rewrite;
}
}
throw new Error(`Could not resolve ${filename}`);
}
/**
* Find package.json
* @param {string} name Name of package.
*/
function findPackageJson(name) {
const packageName = getPackageName(name);
let dirname = NODE_MODULES;
while (true) {
const filename = path.join(dirname, packageName, "package.json");
if (fs.existsSync(filename)) return filename;
dirname = path.normalize(path.join(dirname, ".."));
if (dirname === "/")
throw new Error(`Could not find package.json for ${name}`);
}
}
/**
* Get name of NPM package
* @param {string} name Import string to resolve.
*/
function getPackageName(name) {
const parts = name.split("/");
if (name.startsWith("@")) {
return parts.slice(0, 2).join("/");
}
return parts[0];
}
/**
* Change file extension
*/
async function renameExtension(filename, extn = "mjs") {
await fsp.rename(filename, filename.replace(/\.js$/, `.${extn}`));
}
================================================
FILE: package.json
================================================
{
"name": "root",
"private": true,
"workspaces": ["packages/*"],
"devDependencies": {
"@babel/core": "^7.17.10",
"@babel/plugin-transform-modules-umd": "^7.16.7",
"@babel/preset-env": "^7.17.10",
"@biomejs/biome": "1.9.4",
"@playwright/experimental-ct-react": "^1.27.1",
"@playwright/test": "^1.27.1",
"@rollup/plugin-babel": "^5.3.1",
"@rollup/plugin-commonjs": "^22.0.0",
"@rollup/plugin-node-resolve": "^13.3.0",
"@testing-library/dom": "^8.13.0",
"@testing-library/react": "^13.2.0",
"@testing-library/user-event": "^14.2.0",
"@types/jest": "^27.5.1",
"@types/node": "^22.10.10",
"@types/react": "^18.0.9",
"@types/react-dom": "^18.0.4",
"concurrently": "^7.5.0",
"dotenv": "^16.0.3",
"jest": "^28.1.0",
"jest-environment-jsdom": "^28.1.0",
"playwright": "^1.27.1",
"react": "^18.1.0",
"react-dom": "^18.1.0",
"rollup": "^2.73.0",
"rollup-plugin-dts": "^4.2.1",
"rollup-plugin-terser": "^7.0.2",
"serve": "^14.1.1",
"ts-jest": "^28.0.2",
"ts-node": "^10.7.0",
"typescript": "^5.7.3"
},
"overrides": {
"@types/node": "^22.10.10",
"yargs": "^17.7.2",
"yargs-parser": "^21.1.1"
},
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0"
}
================================================
FILE: packages/captioning/README.md
================================================
# @liqvid/captioning
This package provides audio transcription and captioning utilities for [Liqvid](https://liqvidjs.org). It is used internally by [@liqvid/cli](../cli).
================================================
FILE: packages/captioning/package.json
================================================
{
"name": "@liqvid/captioning",
"version": "1.0.0",
"description": "Audio transcription and captioning for Liqvid",
"files": ["dist/*"],
"main": "dist/index.js",
"repository": {
"type": "git",
"url": "git+https://github.com/liqvidjs/liqvid.git"
},
"author": "Yuri Sulyma <yuri@liqvidjs.org>",
"license": "MIT",
"bugs": {
"url": "https://github.com/liqvidjs/liqvid/issues"
},
"homepage": "https://github.com/liqvidjs/liqvid/tree/main/packages/renderer#readme",
"devDependencies": {
"ibm-watson": "^6.2.1"
},
"peerDependencies": {
"ibm-watson": "^6.2.1"
},
"peerDependenciesMeta": {
"ibm-watson": {
"optional": true
}
}
}
================================================
FILE: packages/captioning/src/index.ts
================================================
export {transcribe} from "./transcription";
export {toWebVTT} from "./webvtt";
================================================
FILE: packages/captioning/src/transcription.ts
================================================
import fs, {promises as fsp} from "fs";
import {IamAuthenticator} from "ibm-watson/auth";
import SpeechToTextV1 from "ibm-watson/speech-to-text/v1";
import path from "path";
import {toWebVTT} from "./webvtt";
/**
* Transcript with per-word timings
*/
export type Transcript = [string, number, number][][];
/**
* Transcribe audio file
*/
export async function transcribe(args: {
/** Path to audio file */
input: string;
/** Path for WebVTT captions */
captions: string;
/** Params to pass to IBM Watson. */
params: Partial<Parameters<SpeechToTextV1["recognize"]>[0]>;
/** Path for rich transcript */
transcript: string;
/** IBM Cloud API key */
apiKey: string;
/** IBM Watson endpoint URL */
apiUrl: string;
}) {
const filename = path.resolve(process.cwd(), args.input);
const output = path.resolve(process.cwd(), args.transcript);
const extn = path.extname(filename);
// SpeechToText instance
const speechToText = new SpeechToTextV1({
authenticator: new IamAuthenticator({
apikey: args.apiKey,
}),
serviceUrl: args.apiUrl,
});
const params = Object.assign(
{
audio: fs.createReadStream(filename),
contentType: `audio/${extn.slice(1)}`,
objectMode: true,
model: "en-US_BroadbandModel",
profanityFilter: false,
smartFormatting: true,
timestamps: true,
},
args.params,
);
// transcribe
const {result: json} = await speechToText.recognize(params);
await fsp.writeFile(args.transcript, JSON.stringify(json, null, 2));
// format
const blockSize = 8;
const words = json.results
.map((_) => _.alternatives[0].timestamps)
.reduce((a, b) => a.concat(b), [] as [string, number, number][])
.map(
([word, t1, t2]: [string, number, number]) =>
[word, Math.floor(t1 * 1000), Math.floor(t2 * 1000)] as [
string,
number,
number,
],
);
const blocks: Transcript = [];
for (let i = 0; i < words.length; i += blockSize) {
blocks.push(words.slice(i, i + blockSize));
}
// save new version
let str = JSON.stringify(blocks, null, 2);
str = str.replace(/(?<!\]),\s+/g, ", ");
str = str.replace(/(?<=\[)\s+/g, "");
str = str.replace(/(?<=\d)\s+(?=\])/g, "");
await fsp.writeFile(output, str);
// make webvtt
const vtt = path.resolve(process.cwd(), args.captions);
await fsp.writeFile(vtt, toWebVTT(blocks));
process.exit(0);
}
================================================
FILE: packages/captioning/src/webvtt.ts
================================================
import {Transcript} from "./transcription";
/**
* Convert rich {@link Transcript} to WebVTT string
* @param transcript Transcript
* @returns WebVTT file as string
*/
export function toWebVTT(transcript: Transcript) {
const captions = ["WEBVTT", ""];
for (let i = 0; i < transcript.length; ++i) {
const line = transcript[i];
if (line.length === 0) continue;
captions.push(String(i + 1));
captions.push(
formatTimeMs(line[0][1]) +
" --> " +
formatTimeMs(line[line.length - 1][2]),
);
captions.push(line.map((_) => _[0]).join(" "));
captions.push("");
}
return captions.join("\n");
}
/* WebVTT requires mm:ss whereas @liqvid/utils/time produces [m]m:ss */
function formatTime(time: number): string {
if (time < 0) {
return "-" + formatTime(-time);
}
const minutes = Math.floor(time / 60 / 1000),
seconds = Math.floor((time / 1000) % 60);
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
function formatTimeMs(time: number): string {
if (time < 0) {
return "-" + formatTimeMs(-time);
}
const milliseconds = Math.floor(time % 1000);
return `${formatTime(time)}.${milliseconds.toString().padStart(3, "0")}`;
}
================================================
FILE: packages/captioning/tsconfig.json
================================================
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["./src"]
}
================================================
FILE: packages/cli/liqvid-cli.mjs
================================================
#! /usr/bin/env node
import * as pkg from "./dist/index.mjs";
pkg
.main()
.then(() => process.exit(0))
.catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
process.exit(1);
});
================================================
FILE: packages/cli/package.json
================================================
{
"name": "@liqvid/cli",
"version": "1.0.5",
"description": "Liqvid command line utility",
"main": "dist/index.js",
"bin": {
"liqvid": "liqvid-cli.mjs"
},
"files": ["dist/*", "liqvid-cli.mjs"],
"sideEffects": false,
"repository": {
"type": "git",
"url": "git+https://github.com/liqvidjs/liqvid.git"
},
"author": "Yuri Sulyma <yuri@liqvidjs.org>",
"license": "MIT",
"bugs": {
"url": "https://github.com/liqvidjs/liqvid/issues"
},
"homepage": "https://github.com/liqvidjs/liqvid#readme",
"scripts": {
"lint": "eslint --ext mts,ts --fix src/"
},
"devDependencies": {
"@types/cli-progress": "^3.9.2",
"@types/yargs": "^17.0.10"
},
"dependencies": {
"@liqvid/captioning": "workspace:^",
"@liqvid/magic": "workspace:^",
"@liqvid/renderer": "workspace:^",
"@liqvid/server": "workspace:^",
"@liqvid/utils": "workspace:^",
"@types/node": "^17.0.23",
"cli-progress": "^3.10.0",
"execa": "^6.1.0",
"ts-node": "^10.7.0",
"webpack": "^5.70.0",
"yargs": "^17.4.1",
"yargs-parser": "^21.0.1"
}
}
================================================
FILE: packages/cli/src/index.mts
================================================
import {readFile} from "fs/promises";
import * as path from "path";
import {fileURLToPath} from "url";
import yargs from "yargs";
import {hideBin} from "yargs/helpers";
// shared options
import {audio} from "./tasks/audio.mjs";
import {build} from "./tasks/build.mjs";
import {serve} from "./tasks/serve.mjs";
import {render} from "./tasks/render.mjs";
import {thumbs} from "./tasks/thumbs.mjs";
// entry
export async function main() {
let config = // WTF
yargs(hideBin(process.argv))
.scriptName("liqvid")
.strict()
.usage("$0 <cmd> [args]")
.demandCommand(1, "Must specify a command");
config = audio(config);
config = build(config);
config = serve(config);
config = render(config);
config = thumbs(config);
// version
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const {version} = JSON.parse(
await readFile(path.join(__dirname, "..", "package.json"), "utf8"),
);
config.version(version);
return config.help().argv;
}
import type {createServer} from "@liqvid/server";
import type {solidify, thumbs as captureThumbs} from "@liqvid/renderer";
import type {buildProject} from "./tasks/build.mjs";
import type {transcribe} from "@liqvid/captioning";
/**
* Configuration object
*/
export interface LiqvidConfig {
audio?: {
transcribe: Partial<Parameters<typeof transcribe>[0]>;
};
build?: Partial<Parameters<typeof buildProject>[0]>;
render?: Partial<Parameters<typeof solidify>[0]>;
serve?: Partial<Parameters<typeof createServer>[0]>;
thumbs?: Partial<Parameters<typeof captureThumbs>[0]>;
}
================================================
FILE: packages/cli/src/tasks/audio.mts
================================================
import path from "path";
import type Yargs from "yargs";
import {DEFAULT_CONFIG, parseConfig} from "./config.mjs";
/**
* Audio utilities
*/
export const audio = (yargs: typeof Yargs) =>
yargs.command("audio", "Audio helpers", (yargs) => {
return (
yargs
// convert command
.command(
"convert <filename>",
"Repair and convert webm recordings",
(yargs) =>
yargs.positional("filename", {
describe: "WebM File to convert",
type: "string",
}),
async (opts) => {
const {convert} = await import("@liqvid/renderer/convert");
await convert(opts);
},
)
// join command
.command(
"join [filenames..]",
"Join audio files into a single file",
(yargs) =>
yargs
.positional("filenames", {
desc: "Filenames to join",
coerce: (filenames: string[]) =>
filenames ? filenames.map((_) => path.resolve(_)) : [],
})
.option("output", {
alias: "o",
desc: "Output file. If not specified, defaults to last input filename.",
coerce: (output?: string) =>
output ? path.resolve(output) : output,
}),
async (opts) => {
const {join} = await import("@liqvid/renderer/join");
await join(opts);
},
)
// transcribe command
.command(
"transcribe",
"Transcribe audio",
(yargs) =>
yargs
.config("config", parseConfig("audio", "transcribe"))
.default("config", DEFAULT_CONFIG)
.option("api-key", {
desc: "IBM API key",
demandOption: true,
type: "string",
})
.option("api-url", {
desc: "IBM Watson endpoint URL",
demandOption: true,
type: "string",
})
.option("input", {
alias: "i",
desc: "Audio filename",
normalize: true,
demandOption: true,
})
.option("captions", {
alias: "c",
default: "./captions.vtt",
desc: "Captions input filename",
normalize: true,
})
.option("transcript", {
alias: "t",
default: "./transcript.json",
desc: "Rich transcript filename",
normalize: true,
})
.option("params", {
desc: "Parameters for IBM Watson",
default: {},
}),
async (opts) => {
const {transcribe} = await import("@liqvid/captioning");
await transcribe(opts);
},
)
.demandCommand(1, "Must specify an audio command")
);
});
================================================
FILE: packages/cli/src/tasks/build.mts
================================================
import {
ScriptData,
scripts as defaultScripts,
StyleData,
styles as defaultStyles,
transform,
} from "@liqvid/magic";
import {promises as fsp} from "fs";
import path from "path";
import webpack from "webpack";
import type Yargs from "yargs";
import {DEFAULT_CONFIG, parseConfig} from "./config.mjs";
// @ts-expect-error TypeScript complains about this not being a module
import loadSync from "./load-sync.cjs";
/**
* Build project
*/
export const build = (yargs: typeof Yargs) =>
yargs.command(
"build",
"Build project",
(yargs) => {
return yargs
.config("config", parseConfig("build"))
.default("config", DEFAULT_CONFIG)
.option("clean", {
alias: "C",
default: false,
desc: "Delete old dist directory before starting",
type: "boolean",
})
.option("out", {
alias: "o",
coerce: path.resolve,
desc: "Output directory",
default: "./dist",
normalize: true,
})
.option("static", {
alias: "s",
coerce: path.resolve,
desc: "Static directory",
default: "./static",
})
.option("scripts", {
coerce: coerceScripts,
desc: "Script aliases",
default: {},
})
.option("styles", {
desc: "Style aliases",
default: {},
});
},
(args) => {
return buildProject(args);
},
);
export async function buildProject(config: {
/** Clean build directory */
clean: boolean;
/** Output directory */
out: string;
/** Static directory */
static: string;
scripts: Record<string, ScriptData>;
styles: Record<string, StyleData>;
}) {
// clean build directory
if (config.clean) {
console.log("Cleaning build directory...");
await fsp.rm(config.out, {force: true, recursive: true});
}
// ensure build directory exists
await fsp.mkdir(config.out, {recursive: true});
// copy static files
console.log("Copying files...");
await buildStatic(config);
// webpack
console.log("Creating production bundle...");
await buildBundle(config);
}
/**
* Copy over static files.
*/
async function buildStatic(config: {
out: string;
static: string;
scripts: Record<string, ScriptData>;
styles: Record<string, StyleData>;
}) {
const staticDir = path.resolve(process.cwd(), config.static);
const scripts = Object.assign({}, defaultScripts, config.scripts);
const styles = Object.assign({}, defaultStyles, config.styles);
await walkDir(staticDir, async (filename) => {
const relative = path.relative(staticDir, filename);
const dest = path.join(config.out, relative);
// apply html magic
if (filename.endsWith(".html")) {
const file = await fsp.readFile(filename, "utf8");
await idemWrite(
dest,
transform(file, {mode: "production", scripts, styles}),
);
} else if (relative === "bundle.js") {
} else {
await fsp.mkdir(path.dirname(dest), {recursive: true});
await fsp.copyFile(filename, dest);
}
});
}
/**
* Compile bundle in production mode.
*/
async function buildBundle(config: {
out: string;
}) {
// configure webpack
process.env.NODE_ENV = "production";
const webpackConfig = loadSync(path.join(process.cwd(), "webpack.config.js"));
webpackConfig.mode = "production";
webpackConfig.output.path = config.out;
const compiler = webpack(webpackConfig);
// watch
return new Promise<void>((resolve) => {
compiler.run((err, stats) => {
if (err) console.error(err);
else {
console.info(stats.toString({color: true}));
}
compiler.close((err, stats) => {
resolve();
});
});
});
}
/**
* Write a file idempotently.
*/
async function idemWrite(filename: string, data: string) {
try {
const old = await fsp.readFile(filename, "utf8");
if (old !== data) await fsp.writeFile(filename, data);
} catch (e) {
await fsp.mkdir(path.dirname(filename), {recursive: true});
await fsp.writeFile(filename, data);
}
}
/**
* Recursively walk a directory.
*/
async function walkDir(
dirname: string,
callback: (filename: string) => Promise<void>,
) {
const files = (await fsp.readdir(dirname)).map((_) => path.join(dirname, _));
await Promise.all(
files.map(async (file) => {
const stats = await fsp.stat(file);
if (stats.isDirectory()) {
return walkDir(file, callback);
} else {
return callback(file);
}
}),
);
}
/**
* Fix files.
*/
function coerceScripts(
json: Record<
string,
| {
crossorigin?: boolean | string;
development?: string;
production?: string;
}
| string
>,
) {
for (const key in json) {
const record = json[key];
if (typeof record === "object") {
if (
typeof record.crossorigin === "string" &&
["true", "false"].includes(record.crossorigin)
) {
record.crossorigin = record.crossorigin === "true";
}
}
}
return json;
}
================================================
FILE: packages/cli/src/tasks/config.mts
================================================
import "ts-node/register/transpile-only";
import os from "os";
import path from "path";
// @ts-expect-error TypeScript complains about this not being a module
import loadSync from "./load-sync.cjs";
export const DEFAULT_LIST = [
"liqvid.config.ts",
"liqvid.config.js",
"liqvid.config.json",
];
export const DEFAULT_CONFIG = DEFAULT_LIST[0];
export function parseConfig(...keys: string[]) {
return (configPath: string) => {
try {
return access(loadSync(configPath), keys);
} catch (e) {
if (e.code === "MODULE_NOT_FOUND") {
// default value => assume not specified
if (path.join(process.cwd(), DEFAULT_CONFIG) === configPath) {
return {};
}
throw e;
} else {
throw e;
}
}
};
}
// function require(filename: string) {
// return JSON.parse(readFileSync(path.resolve(process.cwd(), filename), "utf8"));
// }
function access(o: any, keys: string[]): any {
if (keys.length === 0) return o;
const key = keys.shift();
if (!o[key]) return {};
return access(o[key], keys);
}
export const BROWSER_EXECUTABLE = {
alias: "x",
desc: "Path to a Chrome/ium executable. If not specified and a suitable executable cannot be found, one will be downloaded during rendering.",
normalize: true,
type: "string",
} as const;
export const CONCURRENCY = {
alias: "n",
default: Math.floor(os.cpus().length / 2),
desc: "How many threads to use",
type: "number",
} as const;
================================================
FILE: packages/cli/src/tasks/index.mts
================================================
import {audio} from "./audio.mjs";
import {build} from "./build.mjs";
import {serve} from "./serve.mjs";
import {render} from "./render.mjs";
import {thumbs} from "./thumbs.mjs";
export const commands = [audio, build, render, serve, thumbs];
================================================
FILE: packages/cli/src/tasks/load-sync.cts
================================================
module.exports = function loadSync(path: string) {
return require(path);
};
================================================
FILE: packages/cli/src/tasks/render.mts
================================================
import {parseTime} from "@liqvid/utils/time";
import type Yargs from "yargs";
import {
BROWSER_EXECUTABLE,
CONCURRENCY,
DEFAULT_CONFIG,
parseConfig,
} from "./config.mjs";
/** Render to static video. */
export const render = (yargs: typeof Yargs) =>
yargs.command(
"render",
"Render static video",
(yargs) =>
yargs
.config("config", parseConfig("render"))
.default("config", DEFAULT_CONFIG)
.example([
["liqvid render"],
["liqvid render -a ./audio/audio.webm -o video.webm"],
["liqvid render -u http://localhost:8080/dist/"],
])
// Selection
.group(["audio-file", "output", "url"], "What to render")
.option("audio-file", {
alias: "a",
desc: "Path to audio file",
normalize: true,
})
.option("output", {
alias: "o",
default: "./video.mp4",
desc: "Output filename",
normalize: true,
demandOption: true,
})
.option("url", {
alias: "u",
desc: "URL of video to render",
default: "http://localhost:3000/dist/",
})
// General configuration
.group(
["browser-executable", "concurrency", "config", "help"],
"General options",
)
.option("browser-executable", BROWSER_EXECUTABLE)
.option("concurrency", CONCURRENCY)
// Input options
.group(
["duration", "end", "sequence", "start", "color-scheme"],
"Input options",
)
.option("start", {
alias: "s",
coerce: coerceTime,
default: "00:00",
desc: "Start time, specify as [hh:]mm:ss[.ms]",
type: "string",
})
.option("duration", {
alias: "d",
conflicts: "end",
desc: "Duration, specify as [hh:]mm:ss[.ms]",
type: "string",
})
.coerce("duration", coerceTime)
.option("end", {
alias: "e",
desc: "End time, specify as [hh:]mm:ss[.ms]",
type: "string",
})
.coerce("end", coerceTime)
.option("sequence", {
alias: "S",
desc: "Output image sequence instead of video. If this flag is set, --output will be interpreted as a directory.",
type: "boolean",
})
.option("color-scheme", {
default: "light" as "light" | "dark",
choices: ["light", "dark"] as const,
desc: "Color scheme",
})
// Frames
.group(
["height", "image-format", "quality", "width"],
"Frame formatting",
)
.option("height", {
alias: "h",
default: 800,
desc: "Video height",
})
.option("image-format", {
alias: "F",
choices: ["jpeg", "png"] as const,
default: "jpeg" as "jpeg" | "png",
desc: "Image format for frames",
})
.option("quality", {
alias: "q",
default: 80,
desc: 'Quality for images. Only applies when --image-format is "jpeg"',
})
.option("width", {
alias: "w",
default: 1280,
desc: "Video width",
})
// ffmpeg
.group(
["audio-args", "fps", "pixel-format", "video-args"],
"Video options",
)
.option("audio-args", {
alias: "A",
desc: "Additional flags to pass to ffmpeg, applying to the audio file",
type: "string",
})
.option("fps", {
alias: "r",
default: 30,
desc: "Frames per second",
})
.option("pixel-format", {
alias: "P",
default: "yuv420p",
desc: "Pixel format for ffmpeg",
})
.option("video-args", {
alias: "V",
desc: "Additional flags to pass to ffmpeg, applying to the output video",
type: "string",
})
.version(false),
async (argv) => {
const {solidify} = await import("@liqvid/renderer/solidify");
await solidify(argv);
process.exit(0);
},
);
function coerceTime(v: string): number {
if (typeof v === "undefined") {
return v;
}
try {
return parseTime(v);
} catch (e) {
console.error(`Invalid time: ${v}. Specify as [hh:]mm:ss[.ms]`);
process.exit(1);
}
}
================================================
FILE: packages/cli/src/tasks/serve.mts
================================================
import path from "path";
import type Yargs from "yargs";
import {DEFAULT_CONFIG, parseConfig} from "./config.mjs";
/**
* Run preview server
*/
export const serve = (yargs: typeof Yargs) =>
yargs.command(
"serve",
"Run preview server",
(yargs) =>
yargs
.config("config", parseConfig("serve"))
.default("config", DEFAULT_CONFIG)
.option("build", {
alias: "b",
coerce: path.resolve,
desc: "Build directory",
default: "./dist",
})
.option("livereload-port", {
alias: "L",
desc: "Port to run LiveReload on",
default: 0,
})
.option("port", {
alias: "p",
desc: "Port to run on",
default: 3000,
})
.option("static", {
alias: "s",
coerce: path.resolve,
desc: "Static directory",
default: "./static",
})
.option("scripts", {
desc: "Script aliases",
default: {},
})
.option("styles", {
desc: "Style aliases",
default: {},
}),
async (argv) => {
const {createServer} = await import("@liqvid/server");
// await so script doesn't close
await new Promise(() => {
createServer(argv);
});
},
);
================================================
FILE: packages/cli/src/tasks/thumbs.mts
================================================
import type Yargs from "yargs";
import {
BROWSER_EXECUTABLE,
CONCURRENCY,
DEFAULT_CONFIG,
parseConfig,
} from "./config.mjs";
export const thumbs = (yargs: typeof Yargs) =>
yargs.command(
"thumbs",
"Generate thumbnails",
(yargs) =>
yargs
.config("config", parseConfig("thumbs"))
.default("config", DEFAULT_CONFIG)
.example([
["liqvid thumbs"],
[
"liqvid thumbs -u http://localhost:8080/dist/ -o ./dist/thumbs/%s.jpeg",
],
])
// Selection
.group(["output", "url"], "What to render")
.option("output", {
alias: "o",
default: "./thumbs/%s.jpeg",
desc: "Pattern for output filenames.",
normalize: true,
})
.option("url", {
alias: "u",
desc: "URL of video to generate thumbs for",
default: "http://localhost:3000/dist/",
})
// General
.group(
["browser-executable", "concurrency", "config", "help"],
"General options",
)
.option("browser-executable", BROWSER_EXECUTABLE)
.option("concurrency", CONCURRENCY)
// Format
.group(
[
"color-scheme",
"browser-height",
"browser-width",
"cols",
"frequency",
"height",
"image-format",
"quality",
"rows",
"width",
],
"Formatting",
)
.option("color-scheme", {
default: "light" as "light" | "dark",
choices: ["light", "dark"] as const,
desc: "Color scheme",
})
.option("cols", {
alias: "c",
default: 5,
desc: "The number of columns per sheet",
})
.option("frequency", {
alias: "f",
default: 4,
desc: "How many seconds between screenshots",
})
.option("rows", {
alias: "r",
default: 5,
desc: "The number of rows per sheet",
})
.option("quality", {
alias: "q",
default: 80,
desc: 'Quality for images. Only applies when --image-format is "jpeg"',
})
.option("height", {
alias: "h",
default: 100,
desc: "Height of each thumbnail",
})
.option("width", {
alias: "w",
default: 160,
desc: "Width of each thumbnail",
})
.option("browser-height", {
alias: "H",
desc: "Height of screenshot before resizing",
type: "number",
})
.option("browser-width", {
alias: "W",
desc: "Width of screenshot before resizing",
type: "number",
})
.option("image-format", {
alias: "F",
choices: ["jpeg", "png"] as const,
default: "jpeg" as "jpeg" | "png",
desc: "Image format for thumbnails",
})
.version(false),
async (argv) => {
const {thumbs: renderThumbs} = await import("@liqvid/renderer/thumbs");
await renderThumbs(argv);
process.exit(0);
},
);
================================================
FILE: packages/cli/tsconfig.json
================================================
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src",
"module": "esnext",
"target": "esnext"
},
"include": ["./src"]
}
================================================
FILE: packages/diff/CHANGELOG.md
================================================
## 1.1.0 (April 15, 2024)
Add generics
## 1.0.0 (April 14, 2024)
Initial release
================================================
FILE: packages/diff/README.md
================================================
# @liqvid/diff
This package provides functions to diff Javascript objects and arrays. It is used internally by recording plugins, and as such aims to produce very compact output.
================================================
FILE: packages/diff/jest.config.js
================================================
module.exports = {
preset: "ts-jest",
testPathIgnorePatterns: ["dist"],
transform: {},
};
================================================
FILE: packages/diff/package.json
================================================
{
"name": "@liqvid/diff",
"version": "1.1.0",
"description": "Object-diffing utility",
"exports": {
".": {
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.cjs",
"types": "./dist/types/index.d.ts"
}
},
"typesVersions": {
"*": {
"*": ["./dist/types/*.d.ts"]
}
},
"files": ["dist/*"],
"scripts": {
"build": "pnpm build:clean; pnpm build:js; pnpm build:postclean",
"build:clean": "rm -rf dist",
"build:js": "tsc --outDir dist/esm --module esnext; tsc --outDir dist/cjs --module commonjs; node ../../build.mjs",
"build:postclean": "rm dist/tsconfig.tsbuildinfo",
"lint": "eslint --ext ts,tsx --fix src && eslint --ext ts,tsx --fix tests",
"test": "eslint src --ext ts,tsx && jest --coverage"
},
"repository": {
"type": "git",
"url": "git+https://github.com/liqvidjs/liqvid.git"
},
"author": "Yuri Sulyma <yuri@liqvidjs.org>",
"license": "MIT",
"bugs": {
"url": "https://github.com/liqvidjs/liqvid/issues"
},
"homepage": "https://github.com/liqvidjs/liqvid/tree/main/packages/diff#readme",
"dependencies": {
"@liqvid/utils": "workspace:^"
},
"sideEffects": false
}
================================================
FILE: packages/diff/src/apply.ts
================================================
import type {ArrayDiff, ObjectDiff} from "./types";
import {matchItemDiff, matchRunes, objectKeys} from "./utils";
/**
* Apply a diff to an object.
* @param a - The object to apply the diff to.
* @param b - The diff to apply.
* @returns A new object with the diff applied.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function applyDiff<T>(a: T, b: ObjectDiff<T>): T {
const copy = structuredClone(a);
for (const rkey of objectKeys(b)) {
matchRunes(b, rkey, {
create(key, item) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
copy[key] = item as any;
},
delete(key) {
delete copy[key];
},
array(key, item) {
const target = copy[key];
if (!Array.isArray(target)) {
throw new TypeError("Expected array");
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
copy[key] = applyArrayDiff(target, item) as any;
},
object(key, item) {
const target = copy[key];
if (typeof target !== "object" || target === null) {
throw new TypeError("Expected object");
}
copy[key] = applyDiff(target, item);
},
change(key, item) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
copy[key] = item as any;
},
});
}
return copy;
}
/**
* Apply a diff to an array.
* @param arr - The array to apply the diff to.
* @param diff - The diff to apply.
* @returns A new array with the diff applied.
*/
export function applyArrayDiff<T>(arr: T[], diff: ArrayDiff<T>): T[] {
const [delta, itemDiffs = [], ...appends] = diff;
const copy = arr.slice();
for (const diff of itemDiffs) {
matchItemDiff(diff, {
set(offset, item) {
copy[copy.length - offset] = item as T;
},
array(offset, item) {
copy[copy.length - offset] = applyArrayDiff(
copy[copy.length - offset] as unknown[],
item,
) as T;
},
object(offset, item) {
copy[copy.length - offset] = applyDiff(
copy[copy.length - offset],
item,
) as T;
},
});
}
if (delta < 0) {
copy.splice(copy.length + delta, -delta);
} else {
for (const append of appends) {
copy.push(append as T);
}
}
return copy;
}
================================================
FILE: packages/diff/src/builders.ts
================================================
import {deletePlaceholder, runes} from "./runes";
import type {
ArrayDiff,
ArrayItemDiff,
ChangeItemDiff,
DeletePlaceholder,
ObjectDiff,
RunedKey,
} from "./types";
/**
* Make a diff to create a value.
* @param key Key to use.
* @param value Value to create.
*/
export function creationDiff<K extends string, V>(key: string, value: V) {
return {[`${runes.create}${key}`]: value} as Record<RunedKey<"create", K>, V>;
}
/**
* Make a diff to delete a value.
* @param key Key to use.
* @returns Diff to delete the value.
*/
export function deletionDiff<K extends string>(key: K) {
return {[`${runes.delete}${key}`]: deletePlaceholder} as Record<
RunedKey<"delete", K>,
DeletePlaceholder
>;
}
/**
* Make a diff to update an array.
* @param key Key to use.
* @param diff Array diff to apply.
*/
export function arrayDiff<K extends string, T, D extends ArrayDiff<T>>(
key: K,
diff: D,
) {
return {[`${runes.array}${key}`]: diff} as Record<RunedKey<"array", K>, D>;
}
/**
* Make a diff to update an object.
* @param key Key to use.
* @param diff Object diff to apply.
*/
export function objectDiff<K extends string, T, D extends ObjectDiff<T>>(
key: K,
diff: D,
) {
return {[`${runes.object}${key}`]: diff} as Record<RunedKey<"object", K>, D>;
}
/**
* Make a diff to set a value.
* @param key Key to use.
* @value Value to set.
*/
export function changeDiff<K extends string, V>(key: string, value: V) {
return {[`${runes.change}${key}`]: value} as Record<RunedKey<"change", K>, V>;
}
// item diffs
/**
* Make an item diff to change a value.
* @param offset Offset from the end to change.
* @param value Value to change to.
*/
export function changeItemDiff<T>(offset: number, value: T): ChangeItemDiff<T> {
return [offset, value];
}
/**
* Make an item diff to change an array item.
* @param offset Offset from the end to change.
* @param diff Array diff to apply.
*/
export function arrayItemDiff<T>(
offset: number,
diff: ArrayDiff<T>,
): ArrayItemDiff<T> {
return [`${runes.array}${offset}`, diff];
}
/**
* Make an item diff to change an object item.
* @param index Index to change.
* @param diff Object diff to apply.
*/
export function objectItemDiff<T, D extends ObjectDiff<T>>(
offset: number,
diff: D,
) {
return [`${runes.object}${offset}`, diff] as [RunedKey<"object">, D];
}
================================================
FILE: packages/diff/src/compute.ts
================================================
import {assertType} from "@liqvid/utils/types";
import {
arrayDiff,
arrayItemDiff,
changeDiff,
creationDiff,
deletionDiff,
objectDiff,
objectItemDiff,
} from "./builders";
import type {ArrayDiff, ObjectDiff} from "./types";
import {cmp} from "./utils";
/** Compute the diff between two arrays. */
export function diffArrays<T>(a: T[], b: T[]): ArrayDiff<T> {
// diffs
const itemDiffs: Exclude<ArrayDiff<T>[1], undefined> = [];
for (let i = 0; i < Math.min(a.length, b.length); ++i) {
const itemA = a[i];
const itemB = b[i];
const offset = a.length - i;
if (!cmp(itemA, itemB)) {
// simple replace
if (
typeof itemA !== typeof itemB ||
typeof itemA === "bigint" ||
typeof itemA === "boolean" ||
typeof itemA === "number" ||
typeof itemA === "string" ||
itemA === null ||
itemB === null ||
Array.isArray(itemA) != Array.isArray(itemB)
) {
itemDiffs.push([offset, itemB]);
continue;
}
if (Array.isArray(itemA)) {
assertType<unknown[]>(itemB);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
itemDiffs.push(arrayItemDiff<any>(offset, diffArrays(itemA, itemB)));
} else {
assertType<Record<string, unknown>>(itemA);
assertType<Record<string, unknown>>(itemB);
itemDiffs.push(objectItemDiff(offset, diffObjects(itemA, itemB)));
}
}
}
const delta = b.length - a.length;
// pure deletion
if (itemDiffs.length === 0 && delta <= 0) {
return [delta];
} else {
return [b.length - a.length, itemDiffs, ...b.slice(a.length)];
}
}
/**
* Compute the diff between two objects.
*/
export function diffObjects<T>(a: T, b: T): ObjectDiff<T> {
assertType<Record<string, unknown>>(a);
assertType<Record<string, unknown>>(b);
const ret: ObjectDiff<T> = {};
const keysA = Object.keys(a);
const keysB = new Set(Object.keys(b));
for (const key of keysA) {
if (!keysB.has(key)) {
Object.assign(ret, deletionDiff(key));
continue;
}
const valueA = a[key] as unknown;
const valueB = b[key] as unknown;
if (typeof valueA !== typeof valueB) {
console.warn("Expected same type");
} else {
if (!cmp(valueA, valueB)) {
switch (typeof valueA) {
case "string":
case "number":
case "boolean":
case "bigint":
Object.assign(ret, changeDiff(key, valueB));
break;
case "object":
if (
valueA === null ||
valueB === null ||
Array.isArray(valueA) !== Array.isArray(valueB)
) {
Object.assign(ret, changeDiff(key, valueB));
} else if (Array.isArray(valueA)) {
assertType<unknown[]>(valueB);
Object.assign(ret, arrayDiff(key, diffArrays(valueA, valueB)));
} else {
assertType<object>(valueB);
Object.assign(ret, objectDiff(key, diffObjects(valueA, valueB)));
}
break;
}
}
}
keysB.delete(key);
}
for (const key of keysB) {
Object.assign(ret, creationDiff(key, b[key]));
}
return ret;
}
================================================
FILE: packages/diff/src/index.ts
================================================
export {applyArrayDiff, applyDiff} from "./apply";
export {
arrayDiff,
arrayItemDiff,
changeDiff,
changeItemDiff,
creationDiff,
deletionDiff,
objectDiff,
objectItemDiff,
} from "./builders";
export {diffArrays, diffObjects as diffObjects} from "./compute";
export {mergeArrayDiffs, mergeDiffs} from "./merge";
export type {
ArrayDiff,
ArrayItemDiff,
ChangeItemDiff,
DeletePlaceholder,
ItemDiff,
ObjectDiff,
ObjectItemDiff,
Rune,
RuneName,
RunedKey,
} from "./types";
export {cmp, invertDiff, matchItemDiff, matchRunes} from "./utils";
================================================
FILE: packages/diff/src/merge.ts
================================================
import {assertDefined, assertType} from "@liqvid/utils/types";
import {applyArrayDiff, applyDiff} from "./apply";
import {
arrayDiff,
arrayItemDiff,
changeDiff,
changeItemDiff,
creationDiff,
deletionDiff,
objectDiff,
objectItemDiff,
} from "./builders";
import type {ArrayDiff, ItemDiff, ObjectDiff} from "./types";
import {
consume,
getOffset,
matchItemDiff,
matchRunes,
objectKeys,
} from "./utils";
/** Merge two array diffs. */
export function mergeArrayDiffs<T>(
a: ArrayDiff<T>,
b: ArrayDiff<T>,
): ArrayDiff<T> {
const [deltaA, itemDiffsA = [], ...tailA] = a;
const [deltaB, itemDiffsB = [], ...tailB] = b;
const delta = deltaA + deltaB;
// combine item diffs
const itemDiffs: ItemDiff<T>[] = [];
let iterA = 0;
let iterB = 0;
for (; iterA < itemDiffsA.length || iterB < itemDiffsB.length; ) {
const itemA = itemDiffsA.at(iterA);
const itemB = itemDiffsB.at(iterB);
const offsetA = itemA ? getOffset(itemA[0]) : 0;
const offsetB = itemB ? getOffset(itemB[0]) : 0;
const newOffsetB = offsetB - deltaA;
if (offsetA > newOffsetB) {
assertDefined(itemA);
// skip if deleted by b
if (offsetA > -deltaB) {
itemDiffs.push(itemA);
}
iterA++;
} else if (newOffsetB > offsetA) {
assertDefined(itemB);
if (deltaA >= 0) {
// adjust the tail of A
if (offsetB <= tailA.length) {
const tailOffset = tailA.length - offsetB;
const valueA = tailA[tailOffset];
matchItemDiff(itemB, {
// set
set(_, valueB) {
tailA[tailOffset] = valueB;
},
// array
array(_, valueB) {
assertType<unknown[]>(valueA);
tailA[tailOffset] = applyArrayDiff(valueA, valueB);
},
object(_, valueB) {
assertType<Record<string, unknown>>(valueA);
tailA[tailOffset] = applyDiff(valueA, valueB);
},
});
} else {
itemDiffs.push([newOffsetB, itemB[1]] as ItemDiff<T>);
}
} else {
itemDiffs.push([newOffsetB, itemB[1]] as ItemDiff<T>);
}
iterB++;
} else {
assertDefined(itemA);
assertDefined(itemB);
// offsetA === newOffsetB
matchItemDiff(itemA, {
set(_, valueA) {
matchItemDiff(itemB, {
// change(a) * change(b) = change(b)
set(_, valueB) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
itemDiffs.push(changeItemDiff<any>(offsetA, valueB));
},
// change(a) * array(b) = change(a*b)
array(_, valueB) {
assertType<unknown[]>(valueA);
itemDiffs.push(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
changeItemDiff<any>(offsetA, applyArrayDiff(valueA, valueB)),
);
},
// change(a) * object(b) = change(a*b)
object(_, valueB) {
assertType<object>(valueA);
itemDiffs.push(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
changeItemDiff<any>(offsetA, applyDiff(valueA, valueB)),
);
},
});
},
array(_, valueA) {
matchItemDiff(itemB, {
// array(a) * change(b) = change(b)
set(_, valueB) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
itemDiffs.push(changeItemDiff<any>(offsetA, valueB));
},
// array(a) * array(b) = array(a*b)
array(_, valueB) {
itemDiffs.push(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
arrayItemDiff<any>(offsetA, mergeArrayDiffs(valueA, valueB)),
);
},
});
},
object(_, valueA) {
matchItemDiff(itemB, {
// object(a) * change(b) = change(b)
set(_, valueB) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
itemDiffs.push(changeItemDiff<any>(offsetA, valueB));
},
// object(a) * object(b) = object(a*b)
object(_, valueB) {
itemDiffs.push(
objectItemDiff(offsetA, mergeDiffs(valueA, valueB)),
);
},
});
},
});
iterA++;
iterB++;
}
}
// needs to come afterwards since we modify tailA above
const tail = [
...tailA.slice(0, tailA.length + Math.min(0, deltaB)),
...tailB,
];
return [delta, itemDiffs, ...tail];
}
/** Merge two object diffs. */
export function mergeDiffs<T>(
a: ObjectDiff<T>,
b: ObjectDiff<T>,
): ObjectDiff<T> {
const ret: ObjectDiff<T> = {};
for (const rKeyB of objectKeys(b)) {
matchRunes(b, rKeyB, {
// create
create(key, valueB) {
consume(a, key, {
// delete * create(b) = set(b)
delete() {
Object.assign(ret, changeDiff(key, valueB));
},
none() {
Object.assign(ret, creationDiff(key, valueB));
},
else(name) {
throw new Error(`Invalid merge: ${name}-add`);
},
});
},
// delete
delete(key) {
consume(a, key, {
delete() {
throw new Error("Invalid merge: delete-delete");
},
});
Object.assign(ret, deletionDiff(key));
},
// set
change(key, valueB) {
consume(a, key, {
// create * set(b) = create(b)
create() {
Object.assign(ret, creationDiff(key, valueB));
},
// invalid
delete() {
throw new Error("Invalid merge: delete-set");
},
// _ * set(b) = set(b)
else() {
Object.assign(ret, changeDiff(key, valueB));
},
none() {
Object.assign(ret, changeDiff(key, valueB));
},
});
},
// array
array(key, valueB) {
consume(a, key, {
// create(a) * array(b) = create(a*b)
create(valueA) {
assertType<unknown[]>(valueA);
Object.assign(
ret,
creationDiff(key, applyArrayDiff(valueA, valueB)),
);
},
// set(a) * array(b) = set(a*b)
change(valueA) {
assertType<unknown[]>(valueA);
Object.assign(ret, changeDiff(key, applyArrayDiff(valueA, valueB)));
},
else(name) {
throw new Error(`Invalid merge: ${name}-array`);
},
array(valueA) {
Object.assign(ret, arrayDiff(key, mergeArrayDiffs(valueA, valueB)));
},
none() {
Object.assign(ret, arrayDiff(key, valueB));
},
});
},
// object
object(key, valueB) {
consume(a, key, {
// create(a) * object(b) = object(a*b)
create(valueA) {
assertType<object>(valueA);
Object.assign(ret, creationDiff(key, applyDiff(valueA, valueB)));
},
// set(a) * object(b) = set(a*b)
change(valueA) {
assertType<object>(valueA);
Object.assign(ret, changeDiff(key, applyDiff(valueA, valueB)));
},
else(name) {
throw new Error(`Invalid merge: ${name}-array`);
},
// object(a) * object(b) = object(a*b)
object(valueA) {
Object.assign(ret, objectDiff(key, mergeDiffs(valueA, valueB)));
},
none() {
Object.assign(ret, objectDiff(key, valueB));
},
});
},
});
}
// add anything remaining from a
Object.assign(ret, a);
return ret;
}
================================================
FILE: packages/diff/src/runes.ts
================================================
export const runes = {
array: "#",
change: "=",
create: "+",
delete: "-",
object: "@",
} as const;
export const deletePlaceholder = 0;
================================================
FILE: packages/diff/src/types.ts
================================================
import type {deletePlaceholder, runes} from "./runes";
// runes
export type RuneName = keyof typeof runes;
export type Rune = (typeof runes)[RuneName];
export type RunedKey<
K extends RuneName,
Name extends string = string,
> = `${(typeof runes)[K]}${Name}`;
// array diffs
export type ChangeItemDiff<T> = [offset: number, value: T];
export type ObjectItemDiff<T> = [
offset: RunedKey<"object">,
diff: ObjectDiff<T>,
];
export type ArrayItemDiff<T> = [offset: RunedKey<"array">, diff: ArrayDiff<T>];
/**
* Note that offsets are relative to the **end** of the array.
*/
export type ItemDiff<T> =
| ArrayItemDiff<T>
| ChangeItemDiff<T>
| ObjectItemDiff<T>;
/**
* A record describing how to make changes to an array.
*/
export type ArrayDiff<T> = [
delta: number,
itemDiffs?: ItemDiff<T>[],
...tail: unknown[],
];
// delete placeholder
export type DeletePlaceholder = typeof deletePlaceholder;
/**
* A record describing how to make changes to an object.
*/
export type ObjectDiff<T> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: RunedKey<"array">]: ArrayDiff<any>;
[key: RunedKey<"change">]: unknown;
[key: RunedKey<"create">]: unknown;
[key: RunedKey<"delete">]: DeletePlaceholder;
[key: RunedKey<"object">]: ObjectDiff<T>;
};
================================================
FILE: packages/diff/src/utils.ts
================================================
import {assertType} from "@liqvid/utils/types";
import {applyDiff} from "./apply";
import {diffObjects} from "./compute";
import {runes} from "./runes";
import type {
ArrayDiff,
ItemDiff,
ObjectDiff,
Rune,
RuneName,
RunedKey,
} from "./types";
/** Typed {@link Object.keys} */
export function objectKeys<T extends object>(obj: T): (keyof T)[] {
return Object.keys(obj) as (keyof T)[];
}
/** Comparison function */
export function cmp(a: unknown, b: unknown): boolean {
if (typeof a !== typeof b) return false;
switch (typeof a) {
case "bigint":
case "boolean":
case "function":
case "number":
case "string":
case "symbol":
case "undefined":
return a === b;
}
if (a === null || b === null) return a === b;
assertType<Record<string, unknown>>(a);
assertType<Record<string, unknown>>(b);
const keysA = Object.keys(a);
const keysB = new Set(Object.keys(b));
if (keysA.length !== keysB.size) return false;
return keysA.every((key) => keysB.has(key) && cmp(a[key], b[key]));
}
/**
* Pattern-match on an item diff.
*/
export function matchItemDiff<T, R>(
[offset, item]: ItemDiff<T>,
fns: {
set?: (offset: number, value: unknown) => R;
array?: (offset: number, value: ArrayDiff<T[number & keyof T]>) => R;
object?: (offset: number, value: ObjectDiff<T[string & keyof T]>) => R;
},
): R | undefined {
if (typeof offset === "number") {
return fns.set?.(offset, item);
}
const numeric = getOffset(offset);
if (isRune(offset, runes.array)) {
assertType<ArrayDiff<T[number & keyof T]>>(item);
return fns?.array?.(numeric, item);
} else if (isRune(offset, runes.object)) {
assertType<ObjectDiff<T[string & keyof T]>>(item);
return fns?.object?.(numeric, item);
}
}
export function isRune<R extends Rune>(
key: string,
rune: R,
): key is `${R}${string}` {
return key.startsWith(rune as string);
}
/** Pattern-match on an object diff. */
export function matchRunes<T, R>(
diff: ObjectDiff<T>,
key: keyof ObjectDiff<T>,
fns: {
[name in RuneName]?: (
key: string & keyof T,
rkey: ObjectDiff<T>[RunedKey<name>],
) => R;
},
): R | undefined {
for (const name of Object.keys(fns) as RuneName[]) {
const rune = runes[name];
if (key.startsWith(rune)) {
const fn = fns[name];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return fn(key.slice(rune.length) as any, diff[key] as any);
}
}
}
export function consume<T>(
a: ObjectDiff<T>,
key: string,
fns: {
[$name in RuneName | "else" | "none"]?: $name extends RuneName
? (value: ObjectDiff<T>[RunedKey<$name>]) => unknown
: $name extends "else"
? <K extends RuneName>(
name: K,
value: ObjectDiff<T>[RunedKey<K>],
) => unknown
: () => unknown;
} = {},
) {
for (const name of objectKeys(runes)) {
const rune = runes[name];
const keyA = `${rune}${key}` as const;
if (!(keyA in a)) continue;
const valueA = a[keyA];
delete a[keyA];
const fn = fns[name];
if (fn) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return fn(valueA as any);
}
if (fns.else) {
return fns.else(name, valueA);
}
}
// if nothing matched
return fns.none?.();
}
export function getOffset(offset: ItemDiff<unknown>[0]): number {
if (typeof offset === "number") return offset;
if (isRune(offset, runes.array)) {
return parseInt(offset.slice(runes.array.length), 10);
} else if (isRune(offset, runes.object)) {
return parseInt(offset.slice(runes.object.length), 10);
}
throw new Error(`Invalid index: ${offset}`);
}
export function addToOffset<O extends ItemDiff<unknown>[0]>(
offset: O,
delta: number,
): O {
const result = getOffset(offset) + delta;
if (typeof offset === "number") return result as O;
if (isRune(offset, runes.array)) {
return `${runes.array}${result}` as O;
}
return `${runes.object}${result}` as O;
}
/**
* Invert a diff with respect to an object.
* Note that it is not possible to invert a lone diff.
*/
export function invertDiff<T>(state: T, diff: ObjectDiff<T>): ObjectDiff<T> {
return diffObjects(applyDiff(state, diff), state);
}
================================================
FILE: packages/diff/tests/suite.test.ts
================================================
/* eslint-disable @typescript-eslint/no-explicit-any */
import {applyDiff, diffObjects} from "../src";
describe("diffObjects and applyDiff", () => {
test("property deletion", () => {
const a = {x: 1};
const b = {};
const diff = diffObjects(a, b);
expect(diff).toEqual({"-x": 0});
expect(applyDiff(a, diff)).toEqual(b);
});
test("property additions", () => {
const a = {};
const b = {x: 1};
const diff = diffObjects(a, b);
expect(diff).toEqual({"+x": 1});
expect(applyDiff(a, diff)).toEqual(b);
});
test("property changes", () => {
const a = {x: 1};
const b = {x: 2};
const diff = diffObjects(a, b);
expect(diff).toEqual({"=x": 2});
expect(applyDiff(a, diff)).toEqual(b);
});
test("array appends", () => {
const a = {x: [0, 1, 2]};
const b = {x: [0, 1, 2, 3, 4]};
const diff = diffObjects(a, b);
expect(diff).toEqual({"#x": [2, [], 3, 4]});
expect(applyDiff(a, diff)).toEqual(b);
});
test("array deletions", () => {
const a = {x: [0, 1, 2]};
const b = {x: [0]};
const diff = diffObjects(a, b);
expect(diff).toEqual({"#x": [-2]});
expect(applyDiff(a, diff)).toEqual(b);
});
test("array changes", () => {
const a = {x: [0, 1, 2]};
const b = {x: [0, 3]};
const diff = diffObjects(a, b);
expect(diff).toEqual({"#x": [-1, [[2, 3]]]});
expect(applyDiff(a, diff)).toEqual(b);
});
test("nested objects", () => {
const a = {x: {fruit: "apple", color: "red"}};
const b = {x: {fruit: "potato", kind: "mashed"}};
const diff = diffObjects<any>(a, b);
expect(diff).toEqual({
"@x": {"=fruit": "potato", "+kind": "mashed", "-color": 0},
});
expect(applyDiff(a, diff)).toEqual(b);
});
test("objects nested in arrays", () => {
const a = {
shapes: {
square: {
segments: [{type: "free", points: [0, 1]}],
},
},
};
const b = {
shapes: {
square: {
segments: [{type: "free", points: [0, 1, 2, 3]}],
},
},
};
const diff = diffObjects(a, b);
expect(diff).toEqual({
"@shapes": {
"@square": {
"#segments": [
0,
[
[
"@1",
{
"#points": [2, [], 2, 3],
},
],
],
],
},
},
});
expect(applyDiff(a, diff)).toEqual(b);
});
test("kitchen sink", () => {
const a = {
x: 1,
y: 2,
z: 3,
arr: [1, 2, 3],
obj: {fruit: "apple", color: "red"},
};
const b = {
x: 3,
z: 3,
w: 4,
arr: [1, 5, 4, "x", "y"],
obj: {fruit: "potato", kind: "mashed"},
};
const diff = diffObjects<any>(a, b);
expect(diff).toEqual({
"=x": 3,
"-y": 0,
"+w": 4,
"#arr": [
2,
[
[2, 5],
[1, 4],
],
"x",
"y",
],
"@obj": {
"=fruit": "potato",
"+kind": "mashed",
"-color": 0,
},
});
expect(applyDiff(a, diff)).toEqual(b);
});
});
================================================
FILE: packages/diff/tsconfig.json
================================================
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"declarationDir": "./dist/types",
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["./src"]
}
================================================
FILE: packages/duration/README.md
================================================
# @liqvid/duration
Provides an opaque `Duration` type to handle relative times, avoiding millisecond/second mismatches.
================================================
FILE: packages/duration/package.json
================================================
{
"name": "@liqvid/duration",
"version": "1.1.0",
"description": "Class for unitless time intervals",
"exports": {
".": {
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.cjs",
"types": "./dist/types/index.d.ts"
}
},
"typesVersions": {
"*": {
"*": [
"./dist/types/*.d.ts"
]
}
},
"files": [
"dist/*"
],
"scripts": {
"build": "pnpm build:clean; pnpm build:js; pnpm build:postclean",
"build:clean": "rm -rf dist",
"build:js": "pnpm build:js:cjs; pnpm build:js:esm; pnpm build:js:fix",
"build:js:cjs": "tsc --module commonjs --outDir dist/cjs",
"build:js:esm": "tsc --module esnext --outDir dist/esm",
"build:js:fix": "node ../../build.mjs",
"build:postclean": "rm dist/tsconfig.tsbuildinfo",
"lint": "biome check --fix"
},
"repository": {
"type": "git",
"url": "git+https://github.com/liqvidjs/liqvid.git"
},
"author": "Yuri Sulyma <yuri@liqvidjs.org>",
"license": "MIT",
"bugs": {
"url": "https://github.com/liqvidjs/liqvid/issues"
},
"homepage": "https://github.com/liqvidjs/liqvid/tree/main/packages/duration#readme",
"devDependencies": {
"@biomejs/biome": "catalog:",
"typescript": "catalog:"
},
"sideEffects": false
}
================================================
FILE: packages/duration/src/index.ts
================================================
const SECONDS = 1000,
MINUTES = 60 * SECONDS,
HOURS = 60 * MINUTES,
DAYS = 24 * HOURS,
WEEKS = 7 * DAYS;
/**
* Convenience type representing either a {@link Duration}
* or creation options for one
*/
export type DurationLike = Duration | DurationOptions;
/**
* These are additive, e.g. passing `{seconds: 20, minutes: 5}` is
* equivalent to passing `{seconds: 320}`.
*/
export interface DurationOptions {
milliseconds?: number;
seconds?: number;
minutes?: number;
hours?: number;
days?: number;
weeks?: number;
}
/**
* Interval between two points in time, agnostic of units.
*/
export class Duration {
private __valueMs: number;
constructor({
milliseconds = 0,
seconds = 0,
minutes = 0,
hours = 0,
days = 0,
weeks = 0,
}: DurationOptions = {}) {
this.__valueMs =
weeks * WEEKS +
days * DAYS +
hours * HOURS +
minutes * MINUTES +
seconds * SECONDS +
milliseconds;
}
/**
* Coerce a DurationLike into a Duration
*/
static from(val: DurationLike): Duration {
if (val instanceof Duration) return val;
return new Duration(val);
}
/**
* Create a new {@link Duration} object and receive a callback
* to imperatively set its value. This is useful when you need
* to keep a {@link Duration} object in sync with some changing
* value (e.g. wrapping `currentTime` on a `<video>` element),
* and want to avoid allocating lots of new objects. Keeping the
* setter separate ensures that consumers of your wrapped value
* cannot change it.
*/
static withSetter(
options?: DurationOptions,
): [Duration, { setMilliseconds: (ms: number) => void }] {
const d = new Duration(options);
const setMilliseconds = (ms: number) => {
d.__valueMs = ms;
};
return [d, { setMilliseconds }];
}
/* extractors */
inDays(): number {
return this.__valueMs / DAYS;
}
inHours(): number {
return this.__valueMs / HOURS;
}
inMilliseconds(): number {
return this.__valueMs;
}
inMinutes(): number {
return this.__valueMs / MINUTES;
}
inSeconds(): number {
return this.__valueMs / SECONDS;
}
inWeeks(): number {
return this.__valueMs / WEEKS;
}
/* comparison */
/** lower <= this < upper */
between(lower: DurationLike, upper: DurationLike): boolean {
return this.greaterThanOrEqual(lower) && this.lessThan(upper);
}
equals(other: DurationLike): boolean {
other = Duration.from(other);
return this.__valueMs === other.__valueMs;
}
greaterThan(other: DurationLike): boolean {
return !this.lessThanOrEqual(other);
}
greaterThanOrEqual(other: DurationLike): boolean {
return !this.lessThan(other);
}
lessThan(other: DurationLike): boolean {
other = Duration.from(other);
return this.__valueMs < other.__valueMs;
}
lessThanOrEqual(other: DurationLike): boolean {
other = Duration.from(other);
return this.__valueMs <= other.__valueMs;
}
/* arithmetic */
dividedBy(other: DurationLike): number {
other = Duration.from(other);
return this.__valueMs / other.__valueMs;
}
minus(other: DurationLike): Duration {
other = Duration.from(other);
return new Duration({
milliseconds: this.__valueMs - other.__valueMs,
});
}
plus(other: DurationLike): Duration {
other = Duration.from(other);
return new Duration({
milliseconds: this.__valueMs + other.__valueMs,
});
}
times(factor: number): Duration {
return new Duration({ milliseconds: this.__valueMs * factor });
}
}
================================================
FILE: packages/duration/tsconfig.json
================================================
{
"compilerOptions": {
"composite": true,
"declarationDir": "./dist/types",
"outDir": "./dist",
"rootDir": "./src"
},
"extends": "../../tsconfig.json",
"include": ["./src"]
}
================================================
FILE: packages/gsap/README.md
================================================
# @liqvid/gsap
This module provides [GSAP](https://greensock.com/gsap/) integration for Liqvid.
## Installation
$ npm install @liqvid/gsap
## Usage
See the [GSAP docs](https://greensock.com/docs/) and especially the [React section](https://greensock.com/react).
```tsx
import {useTimeline} from "@liqvid/gsap";
import {useEffect} from "react";
export function Demo() {
const tl = useTimeline();
useEffect(() => {
tl.to(".box", {duration: 3, x: 800});
tl.to(".box", {duration: 3, rotation: 360, y: 500});
tl.to(".box", {duration: 3, x: 0});
}, []);
return (
<section>
<div className="box orange"></div>
<div className="box grey"></div>
<div className="box green"></div>
</section>
);
}
```
================================================
FILE: packages/gsap/package.json
================================================
{
"name": "@liqvid/gsap",
"version": "1.0.1",
"description": "GSAP bindings for Liqvid",
"keywords": ["animation", "GSAP", "Liqvid"],
"main": "./dist/index.js",
"typings": "./dist/index.d.ts",
"files": ["dist/*"],
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/liqvidjs/liqvid.git"
},
"author": "Yuri Sulyma <yuri@liqvidjs.org>",
"license": "MIT",
"bugs": {
"url": "https://github.com/liqvidjs/liqvid/issues"
},
"homepage": "https://github.com/liqvidjs/liqvid#readme",
"peerDependencies": {
"gsap": "^3.9.0",
"liqvid": "workspace:^"
},
"sideEffects": false,
"devDependencies": {
"gsap": "^3.9.1"
}
}
================================================
FILE: packages/gsap/src/index.ts
================================================
import gsap from "gsap";
import {Playback, usePlayer} from "liqvid";
const sym = Symbol();
declare module "liqvid" {
interface Playback {
[sym]: gsap.core.Timeline;
}
}
/**
* Get a GSAP timeline synced with Liqvid playback.
*/
export function useTimeline() {
const {playback} = usePlayer();
if (!playback[sym]) {
playback[sym] = syncTimeline(playback);
}
return playback[sym] as gsap.core.Timeline;
}
/**
* Create a GSAP timeline and sync it with Liqvid playback.
*/
function syncTimeline(playback: Playback) {
const tl = gsap.timeline({paused: true});
playback.hub.on("play", () => tl.resume());
playback.hub.on("pause", () => tl.pause());
playback.hub.on("ratechange", () => tl.timeScale(playback.playbackRate));
playback.hub.on("seek", () => tl.seek(playback.currentTime / 1000));
return tl;
}
================================================
FILE: packages/gsap/tsconfig.json
================================================
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"module": "commonjs"
},
"include": ["./src"]
}
================================================
FILE: packages/host/README.md
================================================
# lv-host
This package provides a script which should be included in pages hosting [Liqvid](https://liqvidjs.org) videos. Currently, all it does is shim fullscreen behavior in iOS.
================================================
FILE: packages/host/lv-host.js
================================================
"use strict";
(() => {
const setDims = () => {
document.body.style.setProperty("--vh", `${window.innerHeight}px`);
document.body.style.setProperty("--vw", `${window.innerWidth}px`);
document.body.style.setProperty("--scroll-y", `${window.scrollY || 0}px`);
};
document.addEventListener("DOMContentLoaded", () => {
// add CSS
{
const style = document.createElement("style");
style.setAttribute("type", "text/css");
style.textContent = `
iframe.fake-fullscreen {
position: fixed;
top: 0;/*var(--scroll-y);*/
left: 0;
height: var(--vh);
width: var(--vw);
z-index: 10000;
}
@media (orientation: portrait) {
iframe.fake-fullscreen {
transform: rotate(-90deg);
transform-origin: top left;
left: 0;
top: 100%;
width: var(--vh);
height: var(--vw);
}
}`;
document.head.appendChild(style);
}
// resize listener
window.addEventListener("resize", setDims);
setDims();
// live collection of iframes
const iframes = document.getElementsByTagName("iframe");
const listener = (e) => {
for (let i = 0; i < iframes.length; ++i) {
const iframe = iframes.item(i);
if (
iframe.allowFullscreen &&
!document.fullscreenEnabled &&
iframe.contentWindow === e.source
) {
// handle the resize event
if ("type" in e.data && e.data.type === "fake-fullscreen") {
// resize event doesn't work reliably in iOS...
setDims();
iframe.classList.toggle("fake-fullscreen", e.data.value);
}
return;
}
}
};
// communicate with children
window.addEventListener("message", listener);
});
})();
================================================
FILE: packages/host/package.json
================================================
{
"name": "@liqvid/host",
"version": "1.1.0",
"description": "Liqvid host page script",
"files": ["lv-host.js"],
"main": "lv-host.js",
"keywords": ["Liqvid", "React", "Javascript"],
"repository": {
"type": "git",
"url": "git+https://github.com/liqvidjs/liqvid.git"
},
"author": "Yuri Sulyma <yuri@liqvidjs.org>",
"license": "MIT",
"bugs": {
"url": "https://github.com/liqvidjs/liqvid/issues"
},
"homepage": "https://github.com/liqvidjs/liqvid#readme"
}
================================================
FILE: packages/hydration/CHANGELOG.md
================================================
# 0.0.2 (Dec 18, 2025)
- add support for [`sessionStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage)
================================================
FILE: packages/hydration/README.md
================================================
# @liqvid/hydration
This package provides some sneaky tricks to get around React hydration errors on statically generated sites.
================================================
FILE: packages/hydration/package.json
================================================
{
"name": "@liqvid/hydration",
"version": "0.0.2",
"description": "Hydration magic for Liqvid",
"exports": {
".": {
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.cjs",
"types": "./dist/types/index.d.ts"
}
},
"typesVersions": {
"*": {
"*": [
"./dist/types/*.d.ts"
]
}
},
"files": [
"dist/*"
],
"scripts": {
"build": "pnpm build:clean; pnpm build:js; pnpm build:postclean",
"build:clean": "rm -rf dist",
"build:js": "pnpm build:js:cjs; pnpm build:js:esm; pnpm build:js:fix",
"build:js:cjs": "tsc --module commonjs --outDir dist/cjs",
"build:js:esm": "tsc --module esnext --outDir dist/esm",
"build:js:fix": "node ../../build.mjs",
"build:postclean": "rm dist/tsconfig.tsbuildinfo",
"lint": "biome check --fix"
},
"repository": {
"type": "git",
"url": "git+https://github.com/liqvidjs/liqvid.git"
},
"author": "Yuri Sulyma <yuri@liqvidjs.org>",
"license": "MIT",
"bugs": {
"url": "https://github.com/liqvidjs/liqvid/issues"
},
"homepage": "https://github.com/liqvidjs/liqvid/tree/main/packages/hydration#readme",
"dependencies": {
"@liqvid/ssr": "workspace:",
"@radix-ui/react-slot": "catalog:"
},
"devDependencies": {
"@biomejs/biome": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"typescript": "catalog:"
},
"peerDependencies": {
"react": ">=18"
},
"sideEffects": false
}
================================================
FILE: packages/hydration/src/HydrateElement.tsx
================================================
import { isClient } from "@liqvid/ssr";
import { Root as Slot } from "@radix-ui/react-slot";
import { useId } from "react";
import { HydrateOnClient } from "./HydrateOnClient";
import type { ArgType, LocalValueConfig } from "./types";
export function HydrateElement<Config extends readonly LocalValueConfig[]>({
children,
hydrationFn,
...props
}: {
children: React.ReactElement;
/**
* You should always pass this with `as const`.
*/
from: Config;
/**
* **🚨WARNING🚨**
* This does not behave like a regular JavaScript function.
* Instead, its literal string representation will be passed down to the client.
* In particular, **you cannot use any external variables or functions** within
* this function.
*
* To avoid confusion, you can instead pass a string; however, a function
* is easier to work with in your editor.
*/
hydrationFn: (
node: HTMLElement,
...args: {
[key in keyof Config]: ArgType<Config[key]>;
}
) => unknown;
}) {
const id = useId();
if (isClient) return children;
return (
<HydrateOnClient
hydrationFn={`(...a)=>{let n=d.getElementById(${JSON.stringify(id)});(${hydrationFn})(n,...a);n.removeAttribute("id")}`}
{...props}
>
<Slot id={id}>{children}</Slot>
</HydrateOnClient>
);
}
================================================
FILE: packages/hydration/src/HydrateOnClient.tsx
================================================
import { isClient } from "@liqvid/ssr";
import { golf } from "./golf";
import { SneakyScript } from "./SneakyScript";
import type { ArgType, LocalValueConfig } from "./types";
export function HydrateOnClient<Config extends readonly LocalValueConfig[]>({
children,
from,
hydrationFn,
}: {
children?: React.ReactNode;
/**
* You should always pass this with `as const`.
*/
from: Config;
/**
* ***🚨 WARNING 🚨***
* ***This does not behave like a regular JavaScript function.***
* Instead, its literal string representation will be passed down to the client.
* In particular, ***you cannot use any external variables or functions*** within this function.
*
* To avoid confusion, you can instead pass a string; however, a function is easier to work with in your editor.
*/
hydrationFn:
| string
| ((
...args: {
[key in keyof Config]: ArgType<Config[key]>;
}
) => unknown);
}) {
if (isClient) return <>{children}</>;
let hasCookies = false;
let hasLocalStorage = false;
let hasSessionStorage = false;
let hasSearchParams = false;
const args = from
.map((lvc) => {
let value: string;
switch (lvc.source ?? "localStorage") {
case "cookie":
hasCookies = true;
value = `${golf.cookies}[${JSON.stringify(lvc.name)}]`;
break;
case "localStorage":
hasLocalStorage = true;
value = `${golf.localStorage}.getItem(${JSON.stringify(lvc.name)})`;
break;
case "sessionStorage":
hasSessionStorage = true;
value = `${golf.sessionStorage}.getItem(${JSON.stringify(lvc.name)})`;
break;
case "search":
hasSearchParams = true;
value = `${golf.url}.get(${JSON.stringify(lvc.name)})`;
break;
}
switch (lvc.type ?? "string") {
case "boolean": {
const defaultValue = lvc.default ?? "null";
return `${value}?${value}=="true":${defaultValue}`;
}
case "number": {
if (typeof lvc.default !== "undefined") {
return `[parseFloat(${value}),${lvc.default}].find(Number.isFinite)`;
}
return `parseFloat(${value})`;
}
// https://github.com/biomejs/biome/issues/7229
// case "string":
default:
if (typeof lvc.default !== "undefined") {
return `${value}??${JSON.stringify(lvc.default)}`;
}
return value;
}
})
.join(",");
return (
<>
{children}
<SneakyScript>
{golf.comma(
`let ${golf.document}=document`,
`${golf.getElementById}=${golf.document}.getElementById.bind(d)`,
hasCookies && cookieScript,
hasLocalStorage && localStorageScript,
hasSessionStorage && sessionStorageScript,
hasSearchParams && searchScript,
) + ";"}
{`(${hydrationFn})(${args})`}
</SneakyScript>
</>
);
}
const cookieScript = `${golf.cookies}=Object.fromEntries(${golf.document}.cookie?.split(/;\\s*/).map(x=>x.split("="))??[]);`;
const localStorageScript = `${golf.localStorage}=localStorage`;
const sessionStorageScript = `${golf.sessionStorage}=sessionStorage`;
const searchScript = `${golf.url}=new URLSearchParams(location.search)`;
================================================
FILE: packages/hydration/src/HydrateVariants.tsx
================================================
import { isClient } from "@liqvid/ssr";
import * as Slot from "@radix-ui/react-slot";
import { useId } from "react";
import { golf } from "./golf";
import { HydrateOnClient } from "./HydrateOnClient";
import type {
BooleanValueConfig,
ComparisonVariant,
NumericValueConfig,
NumericVariant,
StringValueConfig,
StringVariant,
} from "./types";
import { comparisonCondition, matches, stringCondition } from "./utils";
interface BooleanVariantConfig extends BooleanValueConfig {
variants: {
false: React.ReactElement;
true: React.ReactElement;
};
value: boolean;
}
export interface NumericVariantConfig extends NumericValueConfig {
variants: ComparisonVariant<number>[];
value: number;
}
export interface StringVariantConfig extends StringValueConfig {
variants: StringVariant[];
value: string;
}
type VariantConfig =
| BooleanVariantConfig
| NumericVariantConfig
| StringVariantConfig;
/**
* Render one of many possible variants depending on a client value
*/
export function HydrateVariants(props: VariantConfig) {
const id = useId();
const variantNodes = (() => {
switch (props.type) {
case "boolean": {
if (isClient) {
return props.variants[`${props.value}`];
}
return [false, true].map((variant) => (
<Slot.Root id={`${id}-${variant}`} key={String(variant)}>
{props.variants[`${variant}`]}
</Slot.Root>
));
}
case "number":
case "string":
if (isClient) {
const selected = props.variants.find((variant) =>
matches(props.value, variant),
);
if (!selected) {
throw new Error("no matching variant");
}
return <>{selected.children}</>;
} else {
return props.variants.map((variant, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: this is safe
<Slot.Root id={`${id}-${i}`} key={i}>
{variant.children}
</Slot.Root>
));
}
}
})();
return (
<HydrateOnClient
from={[props] as const}
hydrationFn={
(props.type === "boolean" && booleanScript(id)) ||
(props.type === "number" && numberScript(id, props.variants)) ||
(props.type === "string" && stringScript(id, props.variants)) ||
""
}
>
{variantNodes}
</HydrateOnClient>
);
}
const booleanScript = (id: string) =>
[
`(v)=>{`,
`${golf.getElementById}(${JSON.stringify(`${id}-`)}+!v).remove();`,
`${golf.getElementById}(${JSON.stringify(`${id}-`)}+v).removeAttribute("id")`,
`}`,
].join("");
const numberScript = (id: string, options: NumericVariant[]) => {
return golf.join(
`(${golf.value})=>{`,
...options.map((o, index) =>
golf.join(
index === 0 ? "let " : "",
`${golf.node}=${golf.getElementById}(${JSON.stringify(id + "-" + index)});`,
`if(${comparisonCondition(o)})`,
`${golf.node}.removeAttribute("id");`,
`else `,
`${golf.node}.remove();`,
),
),
`}`,
);
};
const stringScript = (id: string, options: StringVariant[]) => {
return golf.join(
`(${golf.value})=>{`,
...options.map((o, index) =>
golf.join(
index === 0 ? "let " : "",
`${golf.node}=${golf.getElementById}(${JSON.stringify(id + "-" + index)});`,
`if(${stringCondition(o)})`,
`${golf.node}.removeAttribute("id");`,
`else `,
`${golf.node}.remove();`,
),
),
`}`,
);
};
================================================
FILE: packages/hydration/src/SneakyScript.tsx
================================================
import { isClient } from "@liqvid/ssr";
type Joinable = false | string | Joinable[];
/**
* Render content as IIFE in a self-removing script tag
* On the client, does nothing
*/
export function SneakyScript({ children }: { children: Joinable }) {
if (isClient) return null;
return (
<script>{`(()=>{${combine(children)};document.currentScript.remove()})()`}</script>
);
}
function combine(content: Joinable): string {
if (typeof content === "string") return content;
if (content === false) return "";
return content.reduce<string>((acc, curr) => {
if (typeof curr === "string") {
acc += curr;
} else if (Array.isArray(curr)) {
acc += combine(curr);
}
return acc;
}, "");
}
================================================
FILE: packages/hydration/src/golf.ts
================================================
export const golf = {
comma: (...vals: (string | false)[]) => vals.filter(Boolean).join(","),
cookies: "c",
document: "d",
getElementById: "$",
join: (...vals: (string | false)[]) => vals.filter(Boolean).join(""),
localStorage: "l",
node: "n",
sessionStorage: "s",
url: "u",
value: "v",
};
================================================
FILE: packages/hydration/src/index.ts
================================================
export { HydrateElement } from "./HydrateElement";
export { HydrateOnClient } from "./HydrateOnClient";
export { HydrateVariants } from "./HydrateVariants";
export { SneakyScript } from "./SneakyScript";
export type * from "./types";
================================================
FILE: packages/hydration/src/types.ts
================================================
/* variant configurations */
export interface BooleanVariant {
false: React.ReactElement;
true: React.ReactElement;
}
export interface ComparisonVariant<T> {
eq?: T;
gt?: T;
gte?: T;
lt?: T;
lte?: T;
children: React.ReactElement;
}
export type NumericVariant = ComparisonVariant<number>;
export interface StringVariant extends ComparisonVariant<string> {
contains?: string;
children: React.ReactElement;
}
export interface VariantsMap {
boolean: BooleanVariant;
number: NumericVariant[];
string: StringVariant[];
}
export type ClientValueSource =
| "cookie"
| "localStorage"
| "search"
| "sessionStorage";
/* configuration */
interface BaseValueConfig {
name: string;
/**
* @default localStorage
*/
source: ClientValueSource;
}
export interface BooleanValueConfig extends BaseValueConfig {
default?: boolean;
type: "boolean";
}
export interface NumericValueConfig extends BaseValueConfig {
default?: number;
type: "number";
}
export interface StringValueConfig<T extends string = string>
extends BaseValueConfig {
default?: T;
enum?: readonly T[];
type?: "string";
}
export type LocalValueConfig =
| BooleanValueConfig
| NumericValueConfig
| StringValueConfig;
export type ArgType<C extends LocalValueConfig> = C["type"] extends "boolean"
? boolean | ("default" extends keyof C ? never : null)
: C["type"] extends "number"
? number | ("default" extends keyof C ? never : null)
:
| ("enum" extends keyof C
? C["enum"] extends ReadonlyArray<infer E>
? E
: never
: string)
| ("default" extends keyof C ? never : null);
================================================
FILE: packages/hydration/src/utils.ts
================================================
import { golf } from "./golf";
import type { ComparisonVariant, StringVariant } from "./types";
// type Joinable = boolean | undefined | null | string | Joinable[];
export const iife = (...lines: (boolean | undefined | null | string)[]) => {
return `(()=>{${lines
.reduce<string[]>((acc, curr) => {
if (!curr) return acc;
if (typeof curr === "string") {
acc.push(curr);
} else if (Array.isArray(curr)) {
acc.push(...curr);
}
return acc;
}, [])
.join(";\n")}})()`;
};
export function matches<T extends string | number>(
value: T,
o: ComparisonVariant<T>,
) {
if (typeof o.eq !== "undefined" && !(value === o.eq)) {
return false;
}
if (typeof o.lt !== "undefined" && !(value < o.lt)) {
return false;
}
if (typeof o.lte !== "undefined" && !(value <= o.lte)) {
return false;
}
if (typeof o.gt !== "undefined" && !(value > o.gt)) {
return false;
}
if (typeof o.gte !== "undefined" && !(value >= o.gte)) {
return false;
}
return true;
}
/**
* Transform a thing
*/
export function comparisonCondition<T>(o: ComparisonVariant<T>) {
const conditions = [];
if (typeof o.eq !== "undefined") {
conditions.push(`${golf.value}===${JSON.stringify(o.eq)}`);
}
if (typeof o.lt !== "undefined") {
conditions.push(`${golf.value}<${JSON.stringify(o.lt)}`);
}
if (typeof o.lte !== "undefined") {
conditions.push(`${golf.value}<=${JSON.stringify(o.lte)}`);
}
if (typeof o.gt !== "undefined") {
conditions.push(`${golf.value}>${JSON.stringify(o.gt)}`);
}
if (typeof o.gte !== "undefined") {
conditions.push(`${golf.value}>=${JSON.stringify(o.gte)}`);
}
return conditions.join("&&");
}
export function stringCondition(o: StringVariant) {
return comparisonCondition(o);
}
================================================
FILE: packages/hydration/tsconfig.json
================================================
{
"compilerOptions": {
"composite": true,
"declarationDir": "./dist/types",
"outDir": "./dist",
"rootDir": "./src"
},
"extends": "../../tsconfig.json",
"include": ["./src"]
}
================================================
FILE: packages/katex/README.md
================================================
# @liqvid/katex
[KaTeX](https://katex.org/) integration for [Liqvid](https://liqvidjs.org). See https://liqvidjs.org/docs/integrations/katex/ for documentation.
================================================
FILE: packages/katex/package.json
================================================
{
"name": "@liqvid/katex",
"version": "0.1.0",
"description": "KaTeX integration for Liqvid",
"files": ["dist/*"],
"exports": {
".": {
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.cjs"
},
"./plain": {
"import": "./dist/esm/plain.mjs",
"require": "./dist/cjs/plain.cjs"
}
},
"typesVersions": {
"*": {
"*": ["./dist/types/*.d.ts"]
}
},
"author": "Yuri Sulyma <yuri@liqvidjs.org>",
"keywords": ["liqvid", "katex"],
"scripts": {
"build": "pnpm build:clean && pnpm build:js && pnpm build:postclean",
"build:clean": "rm -rf dist",
"build:js": "tsc --module esnext --outDir dist/esm; tsc --module commonjs --outDir dist/cjs; node ../../build.mjs",
"build:postclean": "rm dist/tsconfig.tsbuildinfo",
"lint": "eslint --ext ts,tsx --fix src"
},
"repository": {
"type": "git",
"url": "git+https://github.com/liqvidjs/liqvid.git"
},
"bugs": {
"url": "https://github.com/liqvidjs/liqvid/issues"
},
"homepage": "https://github.com/liqvidjs/liqvid/tree/main/packages/katex",
"license": "MIT",
"peerDependencies": {
"@types/katex": ">=0.14.0",
"@types/react": ">=18.0.0",
"liqvid": "workspace:^",
"react": ">=18.1.0"
},
"peerDependenciesMeta": {
"liqvid": {
"optional": true
}
},
"devDependencies": {
"liqvid": "workspace:^"
},
"dependencies": {
"@liqvid/utils": "workspace:^"
},
"sideEffects": false,
"type": "module"
}
================================================
FILE: packages/katex/rollup.config.js
================================================
import dts from "rollup-plugin-dts";
const external = ["@liqvid/utils/react", "react", "react/jsx-runtime.js"];
export default [
// index
{
external: [...external, "liqvid"],
input: "dist/esm/index.mjs",
output: [
// ESM
{file: "./dist/index.mjs", format: "esm"},
// CJS
{file: "./dist/index.cjs", format: "cjs"},
],
},
// plain
{
external,
input: "dist/esm/plain.mjs",
output: [
// ESM
{file: "./dist/plain.mjs", format: "esm"},
// CJS
{file: "./dist/plain.cjs", format: "cjs"},
],
},
// index types
{
input: "dist/types/index.d.ts",
plugins: [dts()],
output: {
file: "dist/index.d.ts",
format: "es",
},
},
// plain types
{
input: "dist/types/plain.d.ts",
plugins: [dts()],
output: {
file: "dist/plain.d.ts",
format: "es",
},
},
];
================================================
FILE: packages/katex/src/RenderGroup.ts
================================================
import {recursiveMap, usePromise} from "@liqvid/utils/react";
import {usePlayer} from "liqvid";
import React, {
cloneElement,
forwardRef,
isValidElement,
useEffect,
useImperativeHandle,
useRef,
} from "react";
import {KTX} from "./fancy";
import {Handle as KTXHandle, KTX as KTXPlain} from "./plain";
/** RenderGroup element API */
interface Handle {
/** Promise that resolves once all KTX descendants have finished typesetting */
ready: Promise<void>;
}
interface Props {
children?: React.ReactNode;
/**
* Whether to reparse descendants for `during()` and `from()`
* @default false
*/
reparse?: boolean;
}
/**
* Wait for several things to be rendered
*/
export const RenderGroup = forwardRef<Handle, Props>(
function RenderGroup(props, ref) {
const [ready, resolve] = usePromise();
// handle
useImperativeHandle(ref, () => ({ready}));
const elements = useRef<HTMLSpanElement[]>([]);
const promises = useRef<Promise<unknown>[]>([]);
// reparsing
const player = usePlayer();
useEffect(() => {
// promises
Promise.all(promises.current).then(() => {
// reparse
if (props.reparse) {
player.reparseTree(leastCommonAncestor(elements.current));
}
// ready()
resolve();
});
}, []);
return recursiveMap(props.children, (node) => {
if (shouldInspect(node)) {
const originalRef = node.ref;
return cloneElement(node, {
ref: (ref: KTXHandle) => {
if (!ref) return;
elements.current.push(ref.domElement);
promises.current.push(ref.ready);
// pass along original ref
if (typeof originalRef === "function") {
originalRef(ref);
} else if (originalRef && typeof originalRef === "object") {
(originalRef as React.MutableRefObject<KTXHandle>).current = ref;
}
},
});
}
return node;
}) as unknown as React.ReactElement;
},
);
/**
* Determine whether the node is a <KTX> element
* @param node Element to check
*/
function shouldInspect(
node: React.ReactNode,
): node is React.ReactElement & React.RefAttributes<KTXHandle> {
return (
isValidElement(node) &&
typeof node.type === "object" &&
(node.type === KTX || node.type === KTXPlain)
);
}
/**
* Find least common ancestor of an array of elements
* @param elements Elements
* @returns Deepest node containing all passed elements
*/
function leastCommonAncestor(elements: HTMLElement[]): HTMLElement {
if (elements.length === 0) {
throw new Error("Must pass at least one element");
}
let ancestor = elements[0];
let failing = elements.slice(1);
while (failing.length > 0) {
ancestor = ancestor.parentElement;
failing = failing.filter((node) => !ancestor.contains(node));
}
return ancestor;
}
================================================
FILE: packages/katex/src/fancy.tsx
================================================
import {combineRefs} from "@liqvid/utils/react";
import {usePlayer} from "liqvid";
import {forwardRef, useEffect, useRef} from "react";
import {Handle, KTX as KTXPlain} from "./plain";
interface Props extends React.ComponentProps<typeof KTXPlain> {
/**
* Player events to obstruct
* @default "canplay canplaythrough"
*/
obstruct?: string;
/**
* Whether to reparse descendants for `during()` and `from()`
* @default false
*/
reparse?: boolean;
}
/** Component for KaTeX code */
export const KTX = forwardRef<Handle, Props>(function KTX(props, ref) {
const {
obstruct = "canplay canplaythrough",
reparse = false,
...attrs
} = props;
const plain = useRef<Handle>();
const combined = combineRefs(plain, ref);
const player = usePlayer();
useEffect(() => {
// obstruction
if (obstruct.match(/\bcanplay\b/)) {
player.obstruct("canplay", plain.current.ready);
}
if (obstruct.match("canplaythrough")) {
player.obstruct("canplaythrough", plain.current.ready);
}
// reparsing
if (reparse) {
plain.current.ready.then(() =>
player.reparseTree(plain.current.domElement),
);
}
}, []);
return <KTXPlain ref={combined} {...attrs} />;
});
================================================
FILE: packages/katex/src/index.tsx
================================================
export {KTX} from "./fancy";
export {KaTeXReady} from "./loading";
export {Handle} from "./plain";
export {RenderGroup} from "./RenderGroup";
declare global {
const katex: typeof katex;
}
================================================
FILE: packages/katex/src/loading.ts
================================================
// option of loading KaTeX asynchronously
const KaTeXLoad = new Promise<typeof katex>((resolve) => {
const script = document.querySelector(
'script[src*="katex.js"], script[src*="katex.min.js"]',
);
if (!script) return;
if (window.hasOwnProperty("katex")) {
resolve(katex);
} else {
script.addEventListener("load", () => resolve(katex));
}
});
// load macros from <head>
const KaTeXMacros = new Promise<{[key: string]: string}>((resolve) => {
const macros: {[key: string]: string} = {};
const scripts: HTMLScriptElement[] = Array.from(
document.querySelectorAll("head > script[type='math/tex']"),
);
return Promise.all(
scripts.map((script) =>
fetch(script.src)
.then((res) => {
if (res.ok) return res.text();
throw new Error(`${res.status} ${res.statusText}: ${script.src}`);
})
.then((tex) => {
Object.assign(macros, parseMacros(tex));
}),
),
).then(() => resolve(macros));
});
/**
* Ready Promise
*/
export const KaTeXReady = Promise.all([KaTeXLoad, KaTeXMacros]);
/**
* Parse \newcommand macros in a file.
* Also supports \ktxnewcommand (for use in conjunction with MathJax).
* @param file TeX file to parse
*/
function parseMacros(file: string) {
const macros: Record<string, string> = {};
const rgx = /\\(?:ktx)?newcommand\{(.+?)\}(?:\[\d+\])?\{/g;
let match: RegExpExecArray;
while ((match = rgx.exec(file))) {
let body = "";
const macro = match[1];
let braceCount = 1;
for (
let i = match.index + match[0].length;
braceCount > 0 && i < file.length;
++i
) {
const char = file[i];
if (char === "{") {
braceCount++;
} else if (char === "}") {
braceCount--;
if (braceCount === 0) break;
} else if (char === "\\") {
body += file.slice(i, i + 2);
++i;
continue;
}
body += char;
}
macros[macro] = body;
}
return macros;
}
================================================
FILE: packages/katex/src/plain.tsx
================================================
import {usePromise} from "@liqvid/utils/react";
import {forwardRef, useEffect, useImperativeHandle, useRef} from "react";
import {KaTeXReady} from "./loading";
/**
* KTX element API
*/
export interface Handle {
/** The underlying <span> element */
domElement: HTMLSpanElement;
/** Promise that resolves once typesetting is finished */
ready: Promise<void>;
}
interface Props extends React.HTMLAttributes<HTMLSpanElement> {
/**
* Whether to render in display style
* @default false
*/
display?: boolean;
}
/** Component for KaTeX code */
export const KTX = forwardRef<Handle, Props>(function KTX(props, ref) {
const spanRef = useRef<HTMLSpanElement>();
const {children, display = false, ...attrs} = props;
const [ready, resolve] = usePromise();
// handle
useImperativeHandle(ref, () => ({
domElement: spanRef.current,
ready,
}));
useEffect(() => {
KaTeXReady.then(([katex, macros]) => {
katex.render(children.toString(), spanRef.current, {
displayMode: !!display,
macros,
strict: "ignore",
throwOnError: false,
trust: true,
});
/* move katex into placeholder element */
const child = spanRef.current.firstElementChild as HTMLSpanElement;
// copy classes
for (let i = 0, len = child.classList.length; i < len; ++i) {
spanRef.current.classList.add(child.classList.item(i));
}
// move children
while (child.childNodes.length > 0) {
spanRef.current.appendChild(child.firstChild);
}
// delete child
child.remove();
// resolve promise
resolve();
});
}, [children]);
// Google Chrome fails without this
if (display) {
if (!attrs.style) attrs.style = {};
attrs.style.display = "block";
}
return <span {...attrs} ref={spanRef} />;
});
================================================
FILE: packages/katex/tsconfig.json
================================================
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"declarationDir": "./dist/types",
"module": "esnext",
"outDir": "./dist/esm",
"rootDir": "./src",
"target": "esnext"
},
"include": ["./src"]
}
================================================
FILE: packages/keymap/CHANGELOG.md
================================================
## 1.2.1 (January 20, 2024)
- include `"use client"` in `@liqvid/keymap/react`
## 1.2.0 (September 13, 2023)
- add `useKeyboardShortcut()`
## 1.1.4 (November 13, 2022)
- don't throw when unbinding callback that hasn't been bound
================================================
FILE: packages/keymap/README.md
================================================
# @liqvid/keymap
This package provides key bindings for [Liqvid](https://liqvidjs.org). See https://liqvidjs.org/docs/reference/KeyMap for documentation.
================================================
FILE: packages/keymap/jest.config.js
================================================
module.exports = {
preset: "ts-jest",
testEnvironment: "jsdom",
testPathIgnorePatterns: ["dist"],
coverageReporters: ["json-summary"],
transform: {},
};
================================================
FILE: packages/keymap/package.json
================================================
{
"name": "@liqvid/keymap",
"version": "1.2.2",
"description": "Key binding for Liqvid",
"repository": {
"type": "git",
"url": "git+https://github.com/liqvidjs/liqvid.git"
},
"exports": {
".": {
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.cjs",
"types": "./dist/types/index.d.ts"
},
"./react": {
"import": "./dist/esm/react.mjs",
"require": "./dist/cjs/react.cjs",
"types": "./dist/types/react.d.ts"
}
},
"typesVersions": {
"*": {
"*": ["./dist/types/*"]
}
},
"files": ["dist/*"],
"scripts": {
"build": "pnpm build:clean && pnpm build:js && pnpm build:postclean",
"build:clean": "rm -rf dist",
"build:js": "tsc --module esnext --outDir dist/esm; tsc --module commonjs --outDir dist/cjs; node ../../build.mjs",
"build:postclean": "find ./dist -name tsconfig.tsbuildinfo -delete",
"lint": "eslint --ext ts,tsx --fix src && eslint --ext ts,tsx --fix tests",
"test": "jest"
},
"author": "Yuri Sulyma <yuri@liqvidjs.org>",
"license": "MIT",
"bugs": {
"url": "https://github.com/liqvidjs/liqvid/issues"
},
"homepage": "https://github.com/liqvidjs/liqvid/tree/main/packages/keymap#readme",
"sideEffects": false,
"peerDependencies": {
"@types/react": ">=17.0.0",
"react": ">=17.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
}
}
================================================
FILE: packages/keymap/src/index.ts
================================================
import {mixedCaseVals} from "./mixedCaseVals";
type Callback = (e: KeyboardEvent) => void;
interface Bindings {
[key: string]: Callback[];
}
const modifierMap = {
Control: "Ctrl",
Alt: "Alt",
Shift: "Shift",
Meta: "Meta",
};
const mixedCase: {[key: string]: string} = {};
for (const key of mixedCaseVals) {
mixedCase[key.toLowerCase()] = key;
}
const modifierOrder = (
Object.keys(modifierMap) as (keyof typeof modifierMap)[]
).map((k) => modifierMap[k]);
const useCode = ["Backspace", "Enter", "Space", "Tab"];
/** Maps keyboard shortcuts to actions */
export class Keymap {
private __bindings: Bindings;
constructor() {
this.__bindings = {};
}
/** Given a KeyboardEvent, returns a shortcut sequence matching that event. */
static identify(e: KeyboardEvent) {
const parts: string[] = [];
for (const modifier in modifierMap) {
if (e.getModifierState(modifier)) {
parts.push(modifierMap[modifier as keyof typeof modifierMap]);
}
}
if (e.key in modifierMap) {
} else if (e.code.startsWith("Digit")) {
parts.push(e.code.slice(5));
} else if (e.code.startsWith("Key")) {
parts.push(e.code.slice(3));
} else if (useCode.includes(e.code)) {
parts.push(e.code);
} else {
parts.push(e.key);
}
return parts.join("+");
}
/** Returns a canonical form of the shortcut sequence. */
static normalize(seq: string) {
return seq
.split("+")
.map((str) => {
const lower = str.toLowerCase();
if (str === "") return "";
if (mixedCase[lower]) {
return mixedCase[lower];
}
return str[0].toUpperCase() + lower.slice(1);
})
.sort((a, b) => {
if (modifierOrder.includes(a)) {
if (modifierOrder.includes(b)) {
return modifierOrder.indexOf(a) - modifierOrder.indexOf(b);
} else {
return -1;
}
} else if (modifierOrder.includes(b)) {
return 1;
} else {
return cmp(a, b);
}
})
.join("+");
}
/**
* Bind a handler to be called when the shortcut sequence is pressed.
* @param seq Shortcut sequence
* @param cb Callback function
*/
bind(seq: string, cb: Callback) {
if (seq.indexOf(",") > -1) {
for (const atomic of seq.split(",")) {
this.bind(atomic, cb);
}
return;
}
seq = Keymap.normalize(seq);
if (!this.__bindings.hasOwnProperty(seq)) {
this.__bindings[seq] = [];
}
this.__bindings[seq].push(cb);
}
/**
* Unbind a handler from a shortcut sequence.
* @param seq Shortcut sequence
* @param cb Handler to unbind
*/
unbind(seq: string, cb: Callback) {
if (seq.indexOf(",") > -1) {
for (const atomic of seq.split(",")) {
this.unbind(atomic, cb);
}
return;
}
seq = Keymap.normalize(seq);
if (!this.__bindings.hasOwnProperty(seq)) {
return;
}
const index = this.__bindings[seq].indexOf(cb);
if (index < 0) {
return;
}
this.__bindings[seq].splice(index, 1);
if (this.__bindings[seq].length === 0) {
delete this.__bindings[seq];
}
}
/** Return all shortcut sequences with handlers bound to them. */
getKeys() {
return Object.keys(this.__bindings);
}
/** Get the list of handlers for a given shortcut sequence. */
getHandlers(seq: string) {
if (!this.__bindings.hasOwnProperty(seq)) return [];
return this.__bindings[seq].slice();
}
/** Dispatches all handlers matching the given event. */
handle(e: KeyboardEvent) {
const seq = Keymap.identify(e);
if (!this.__bindings[seq] && !this.__bindings["*"]) return;
if (this.__bindings[seq]) {
e.preventDefault();
for (const cb of this.__bindings[seq]) {
cb(e);
}
}
if (this.__bindings["*"]) {
for (const cb of this.__bindings["*"]) {
cb(e);
}
}
}
}
/**
* Returns -1 if a < b, 0 if a === b, and 1 if a > b.
*/
function cmp<T>(a: T, b: T) {
if (a < b) return -1;
else if (a === b) return 0;
return 1;
}
================================================
FILE: packages/keymap/src/mixedCaseVals.ts
================================================
export const mixedCaseVals = [
"AltGraph",
"CapsLock",
"FnLock",
"NumLock",
"ScrollLock",
"SymbolLock",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"PageDown",
"PageUp",
"CrSel",
"EraseEof",
"ExSel",
"ContextMenu",
"ZoomIn",
"ZoomOut",
"BrightnessDown",
"BrightnessUp",
"LogOff",
"PowerOff",
"PrintScreen",
"WakeUp",
"AllCandidates",
"CodeInput",
"FinalMode",
"GroupFirst",
"GroupLast",
"GroupNext",
"GroupPrevious",
"ModeChange",
"NextCandidate",
"NonConvert",
"PreviousCandidate",
"SingleCandidate",
"HangulMode",
"HanjaMode",
"JunjaMode",
"HiraganaKatakana",
"KanaMode",
"KanjiMode",
"ZenkakuHanaku",
"AppSwitch",
"CameraFocus",
"EndCall",
"GoBack",
"GoHome",
"HeadsetHook",
"LastNumberRedial",
"MannerMode",
"VoiceDial",
"ChannelDown",
"ChannelUp",
"MediaFastForward",
"MediaPause",
"MediaPlay",
"MediaPlayPause",
"MediaRecord",
"MediaRewind",
"MediaStop",
"MediaTrackNext",
"MediaTrackPrevious",
"AudioBalanceLeft",
"AudioBalanceRight",
"AudioBassDown",
"AudioBassBoostDown",
"AudioBassBoostToggle",
"AudioBassBoostUp",
"AudioBassUp",
"AudioFaderFront",
"AudioFaderRear",
"AudioSurroundModeNext",
"AudioTrebleDown",
"AudioTrebleUp",
"AudioVolumeDown",
"AudioVolumeMute",
"AudioVolumeUp",
"MicrophoneToggle",
"MicrophoneVolumeDown",
"MicrophoneVolumeMute",
"MicrophoneVolumeUp",
"TV",
"TVAntennaCable",
"TVAudioDescription",
"TVAudioDescriptionMixDown",
"TVAudioDescriptionMixUp",
"TVContentsMenu",
"TVDataService",
"TVInput",
"TVMediaContext",
"TVNetwork",
"TVNumberEntry",
"TVPower",
"TVRadioService",
"TVSatellite",
"TVSatelliteBS",
"TVSatelliteCS",
"TVSatelliteToggle",
"TVTerrestrialAnalog",
"TVTerrestrialDigital",
"TVTimer",
"AVRInput",
"AVRPower",
"ClosedCaptionToggle",
"DisplaySwap",
"DVR",
"GuideNextDay",
"GuidePreviousDay",
"InstantReplay",
"ListProgram",
"LiveContent",
"MediaApps",
"MediaAudioTrack",
"MediaLast",
"MediaSkipBackward",
"MediaSkipForward",
"MediaStepBackward",
"MediaStepForward",
"MediaTopMenu",
"NavigateIn",
"NavigateNext",
"NavigateOut",
"NavigatePrevious",
"NextFavoriteChannel",
"NextUserProfile",
"OnDemand",
"PinPDown",
"PinPMove",
"PinPToggle",
"PinPUp",
"PlaySpeedDown",
"PlaySpeedReset",
"PlaySpeedUp",
"RandomToggle",
"RcLowBattery",
"RecordSpeedNext",
"RfBypass",
"ScanChannelsToggle",
"ScreenModeNext",
"SplitScreenToggle",
"STBInput",
"STBPower",
"VideoModeNext",
"ZoomToggle",
"SpeechCorrectionList",
"SpeechInputToggle",
"SpellCheck",
"MailForward",
"MailReply",
"MailSend",
"LaunchCalculator",
"LaunchCalendar",
"LaunchContacts",
"LaunchMail",
"LaunchMediaPlayer",
"LaunchMusicPlayer",
"LaunchMyComputer",
"LaunchPhone",
"LaunchScreenSaver",
"LaunchSpreadsheet",
"LaunchWebBrowser",
"LaunchWebCam",
"LaunchWordProcessor",
"BrowserBack",
"BrowserFavorites",
"BrowserForward",
"BrowserHome",
"BrowserRefresh",
"BrowserSearch",
"BrowserStop",
];
================================================
FILE: packages/keymap/src/react.ts
================================================
"use client";
import {createContext, useContext, useEffect} from "react";
import type {Keymap} from ".";
const symbol = Symbol.for("@lqv/keymap");
type GlobalThis = {
[symbol]: React.Context<Keymap>;
};
if (!(symbol in globalThis)) {
(globalThis as unknown as GlobalThis)[symbol] = createContext<Keymap>(null);
}
/**
* {@link React.Context} used to access ambient Keymap
*/
export const KeymapContext = (globalThis as unknown as GlobalThis)[symbol];
KeymapContext.displayName = "Keymap";
/** Access the ambient {@link Keymap} */
export function useKeymap() {
return useContext(KeymapContext);
}
/** Register a keyboard shortcut for the duration of the component. */
export function useKeyboardShortcut(
/** Keyboard sequence to bind to */
seq: string,
/** Callback to handle the shortcut */
callback: (e: KeyboardEvent) => unknown,
) {
const keymap = useKeymap();
useEffect(() => {
keymap.bind(seq, callback);
return () => keymap.unbind(seq, callback);
}, [callback, keymap, seq]);
}
================================================
FILE: packages/keymap/tests/keymap.test.ts
================================================
import {Keymap} from "../src/index";
/* Modifier keys cannot be tested in Keymap::identify and Keymap.handle
due to a bug in jsdom: https://github.com/jsdom/jsdom/issues/3126
*/
test("Keymap::identify", () => {
const e = new KeyboardEvent("keyup", {key: "a", code: "KeyA"});
expect(Keymap.identify(e)).toBe("A");
});
test("Keymap::normalize", () => {
expect(Keymap.normalize("A+Shift+Ctrl")).toBe("Ctrl+Shift+A");
expect(Keymap.normalize("q+alt+ctrl")).toBe("Ctrl+Alt+Q");
});
describe("Keymap bind handling", () => {
const keymap = new Keymap();
const cb = jest.fn();
const cb2 = jest.fn();
keymap.bind("A", cb);
keymap.bind("B", cb2);
test("getHandlers", () => {
expect(keymap.getHandlers("A")).toEqual([cb]);
expect(keymap.getHandlers("B")).toEqual([cb2]);
});
test("getKeys", () => {
expect(keymap.getKeys()).toEqual(["A", "B"]);
});
test("unbind", () => {
expect(() => keymap.unbind("C", cb)).not.toThrow();
expect(() => keymap.unbind("B", cb)).not.toThrow();
keymap.unbind("A", cb);
expect(keymap.getHandlers("A")).toEqual([]);
});
test("handle", () => {
const e = new KeyboardEvent("keyup", {key: "B", code: "KeyB"});
keymap.handle(e);
expect(cb2).toHaveBeenCalledTimes(1);
expect(cb2).toHaveBeenCalledWith(e);
});
});
================================================
FILE: packages/keymap/tsconfig.json
================================================
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"declarationDir": "./dist/types",
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["./src"]
}
================================================
FILE: packages/magic/README.md
================================================
# @liqvid/magic
This package provides template macros for [Liqvid](https://liqvidjs.org). See https://liqvidjs.org/docs/cli/macros/ for documentation.
================================================
FILE: packages/magic/jest.config.js
================================================
module.exports = {
preset: "ts-jest",
testEnvironment: "jsdom",
testPathIgnorePatterns: ["dist"],
coverageReporters: ["json-summary"],
transform: {},
};
================================================
FILE: packages/magic/package.json
================================================
{
"name": "@liqvid/magic",
"version": "1.1.2",
"description": "Templating functions for Liqvid",
"main": "./dist/index.js",
"typings": "./dist/index.d.ts",
"files": ["dist/*"],
"repository": {
"type": "git",
"url": "git+https://github.com/liqvidjs/liqvid.git"
},
"author": "Yuri Sulyma <yuri@liqvidjs.org>",
"license": "MIT",
"bugs": {
"url": "https://github.com/liqvidjs/liqvid/issues"
},
"scripts": {
"build": "tsc --build --force",
"lint": "eslint --ext ts,tsx --fix src && eslint --ext ts,tsx --fix tests",
"test": "jest"
},
"homepage": "https://github.com/liqvidjs/liqvid/tree/main/packages/magic#readme",
"sideEffects": false
}
================================================
FILE: packages/magic/src/default-assets.ts
================================================
import type {ScriptData, StyleData} from "./types";
export const scripts: Record<string, ScriptData> = {
host: "https://unpkg.com/@liqvid/host/lv-host.js",
liqvid: {
crossorigin: true,
development: "https://unpkg.com/liqvid@2.1.4/dist/liqvid.js",
production: "https://unpkg.com/liqvid@2.1.4/dist/liqvid.min.js",
integrity:
"sha384-o8Svf9aNpbI8MzaCkJ0rPo5OxnnZ9Zf86Z18azwsy6rPuenc22zYvNwyv49wIgWa",
},
livereload: {},
polyfills: "https://unpkg.com/@liqvid/polyfills/dist/waapi.js",
rangetouch: {
crossorigin: true,
development: "https://cdn.rangetouch.com/2.0.1/rangetouch.js",
integrity:
"sha384-ImWMbbJ1rSn1mn+2vsKm/wN6Vc7hPNB2VKN0lX3FAzGK+c7M2mD6ZZcwknuKlP7K",
production: "https://cdn.rangetouch.com/2.0.1/rangetouch.js",
},
react: {
crossorigin: true,
development: "https://unpkg.com/react@17.0.2/umd/react.development.js",
production: "https://unpkg.com/react@17.0.2/umd/react.production.min.js",
integrity:
"sha384-7Er69WnAl0+tY5MWEvnQzWHeDFjgHSnlQfDDeWUvv8qlRXtzaF/pNo18Q2aoZNiO",
},
"react-dom": {
crossorigin: true,
development:
"https://unpkg.com/react-dom@17.0.2/umd/react-dom.development.js",
production:
"https://unpkg.com/react-dom@17.0.2/umd/react-dom.production.min.js",
integrity:
"sha384-vj2XpC1SOa8PHrb0YlBqKN7CQzJYO72jz4CkDQ+ePL1pwOV4+dn05rPrbLGUuvCv",
},
recording: {
crossorigin: true,
development: "https://unpkg.com/rp-recording@2.1.1/dist/rp-recording.js",
},
};
export const styles: Record<string, StyleData> = {
liqvid: {
development: "https://unpkg.com/liqvid@2.1.4/dist/liqvid.css",
production: "https://unpkg.com/liqvid@2.1.4/dist/liqvid.min.css",
},
};
================================================
FILE: packages/magic/src/index.ts
================================================
import type {ScriptData, StyleData} from "./types";
export type {ScriptData, StyleData} from "./types";
/**
* Template function.
*/
export function transform(
content: string,
config: {
mode: "development" | "production";
scripts: Record<string, ScriptData>;
styles: Record<string, StyleData>;
},
) {
// insert scripts
content = content.replaceAll(
/<!-- @script "(.+?)" -->/g,
(match, label: string) => {
const script = config.scripts[label];
if (!script) {
console.warn(`Missing script ${label}`);
return match;
}
if (typeof script === "string") {
return tag("script", {src: script});
} else {
const handler = script[config.mode];
if (!handler) {
return "";
}
if (typeof handler === "string") {
const attrs: Record<string, string> = {};
if (script.crossorigin) {
attrs.crossorigin = "anonymous"; //script.crossorigin;
}
if (config.mode === "production" && script.integrity) {
attrs.integrity = script.integrity;
}
attrs.src = handler;
return tag("script", attrs);
} else {
return tag("script", {}, handler);
}
}
},
);
// insert styles
content = content.replaceAll(
/<!-- @style "(.+?)" -->/g,
(match, label: string) => {
const style = config.styles[label];
if (!style) {
console.warn(`Missing style ${label}`);
return match;
}
const attrs: Record<string, string> = {
rel: "stylesheet",
type: "text/css",
};
if (typeof style === "string") {
return tag("link", {href: style, ...attrs}, true);
} else {
const handler = style[config.mode];
if (!handler) {
return "";
}
if (typeof handler === "string") {
return tag("link", {href: style[config.mode], ...attrs}, true);
}
}
return tag("link", attrs, true);
},
);
// insert json
content = content.replaceAll(
/<!-- @json "(.+?)" "(.+?)" -->/g,
(match, label: string, src: string) => {
return tag(
"link",
{
as: "fetch",
"data-name": label,
href: src,
rel: "preload",
type: "application/json",
},
true,
);
},
);
// return
return content;
}
/**
* Create an HTML tag.
*/
export function tag<K extends keyof HTMLElementTagNameMap>(
tagName: K,
attrs: Record<string, boolean | string> = {},
nextOrClose: boolean | number | string | (() => string) = false,
) {
const close = nextOrClose === true;
const attrString = Object.keys(attrs)
.map((attr) => {
if (!attrs.hasOwnProperty(attr)) return "";
if ("boolean" === typeof attrs[attr]) {
if (attrs[attr]) return ` ${attr}`;
return "";
}
// XXX make sure this is correct escaping
const escaped = attrs[attr].toString().replace(/"/g, """);
return ` ${attr}="${escaped}"`;
})
.join("");
const str = `<${tagName}${attrString}`;
if (close) return `${str}/>`;
let content;
switch (typeof nextOrClose) {
case "function":
content = nextOrClose();
break;
case "number":
case "string":
content = nextOrClose;
break;
default:
content = "";
break;
}
return `${str}>${content}</${tagName}>`;
}
export {scripts, styles} from "./default-assets";
================================================
FILE: packages/magic/src/types.ts
================================================
export type ScriptData =
| {
/**
* Whether script is crossorigin.
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attr-crossorigin
*/
crossorigin?: boolean | string;
/**
* Whether to apply the defer attribute
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attr-defer
*/
defer?: boolean;
/**
* Development src.
*/
development?: string | (() => string);
/**
* Integrity attribute for production.
*/
integrity?: string;
/**
* Production src.
*/
production?: string | (() => string);
}
| string;
export type StyleData =
| {
/**
* Development href.
*/
development?: string;
/**
* Production href.
*/
production?: string;
}
| string;
================================================
FILE: packages/magic/tests/magic.test.ts
================================================
import exp from "constants";
import {tag, transform, scripts, styles} from "..";
jest.spyOn(console, "warn").mockImplementation(() => {});
// describe("default assets", () => {
// });
test("@json", () => {
const content = `<!-- @json "test" "./test.json" -->`;
const str = transform(content, {
mode: "development",
scripts: {},
styles: {},
});
expect(str).toBe(
`<link as="fetch" data-name="test" href="./test.json" rel="preload" type="application/json"/>`,
);
});
describe("@script", () => {
const config = {
scripts: {
basic: {
development: "https://dev.com",
production: "https://prod.com",
},
devOnly: {
development: "https://dev.only",
},
prodOnly: {
production: "https://prod.only",
},
single: "https://same-url.com",
withIntegrity: {
crossorigin: "anonymous",
defer: true,
integrity: "sha384",
development: "https://dev.com",
production: "https://prod.com",
},
},
styles: {},
};
test("single script", () => {
const content = `<!-- @script "single" -->`;
expect(transform(content, {mode: "development", ...config})).toBe(
`<script src="https://same-url.com"></script>`,
);
expect(transform(content, {mode: "production", ...config})).toBe(
`<script src="https://same-url.com"></script>`,
);
});
test("mode selection", () => {
const content = `<!-- @script "basic" -->`;
expect(transform(content, {mode: "development", ...config})).toBe(
`<script src="https://dev.com"></script>`,
);
expect(transform(content, {mode: "production", ...config})).toBe(
`<script src="https://prod.com"></script>`,
);
});
test("integrity attribute", () => {
const content = `<!-- @script "withIntegrity" -->`;
expect(transform(content, {mode: "development", ...config})).toBe(
`<script crossorigin="anonymous" src="https://dev.com"></script>`,
);
expect(transform(content, {mode: "production", ...config})).toBe(
`<script crossorigin="anonymous" integrity="sha384" src="https://prod.com"></script>`,
);
});
test("complain about missing script", () => {
const content = `<!-- @script "missing" -->`;
expect(transform(content, {mode: "development", ...config})).toBe(content);
expect(console.warn).toHaveBeenCalledWith("Missing script missing");
expect(transform(content, {mode: "production", ...config})).toBe(content);
expect(console.warn).toHaveBeenCalledWith("Missing script missing");
});
test("dev only", () => {
const content = `<!-- @script "devOnly" -->`;
expect(transform(content, {mode: "development", ...config})).toBe(
`<script src="https://dev.only"></script>`,
);
expect(transform(content, {mode: "production", ...config})).toBe("");
});
test("prod only", () => {
const content = `<!-- @script "prodOnly" -->`;
expect(transform(content, {mode: "development", ...config})).toBe("");
expect(transform(content, {mode: "production", ...config})).toBe(
`<script src="https://prod.only"></script>`,
);
});
});
describe("@styles", () => {
const config = {
scripts: {},
styles: {
basic: {
development: "https://dev.com",
production: "https://prod.com",
},
devOnly: {
development: "https://dev.only",
},
prodOnly: {
production: "https://prod.only",
},
single: "https://same-url.com",
withIntegrity: {
crossorigin: "anonymous",
defer: true,
integrity: "sha384",
development: "https://dev.com",
production: "https://prod.com",
},
},
};
test("single style", () => {
const content = `<!-- @style "single" -->`;
expect(transform(content, {mode: "development", ...config})).toBe(
`<link href="https://same-url.com" rel="stylesheet" type="text/css"/>`,
);
expect(transform(content, {mode: "production", ...config})).toBe(
`<link href="https://same-url.com" rel="stylesheet" type="text/css"/>`,
);
});
test("mode selection", () => {
const content = `<!-- @style "basic" -->`;
expect(transform(content, {mode: "development", ...config})).toBe(
`<link href="https://dev.com" rel="stylesheet" type="text/css"/>`,
);
expect(transform(content, {mode: "production", ...config})).toBe(
`<link href="https://prod.com" rel="stylesheet" type="text/css"/>`,
);
});
test("complain about missing style", () => {
const content = `<!-- @style "missing" -->`;
expect(transform(content, {mode: "development", ...config})).toBe(content);
expect(console.warn).toHaveBeenCalledWith("Missing style missing");
expect(transform(content, {mode: "production", ...config})).toBe(content);
expect(console.warn).toHaveBeenCalledWith("Missing style missing");
});
test("dev only", () => {
const content = `<!-- @style "devOnly" -->`;
expect(transform(content, {mode: "development", ...config})).toBe(
`<link href="https://dev.only" rel="stylesheet" type="text/css"/>`,
);
expect(transform(content, {mode: "production", ...config})).toBe("");
});
test("prod only", () => {
const content = `<!-- @style "prodOnly" -->`;
expect(transform(content, {mode: "development", ...config})).toBe("");
expect(transform(content, {mode: "production", ...config})).toBe(
`<link href="https://prod.only" rel="stylesheet" type="text/css"/>`,
);
});
});
describe("tag", () => {
test("no args", () => {
expect(tag("p")).toBe(`<p></p>`);
});
test("attrs", () => {
expect(tag("script", {src: "test.js", type: "text/javascript"})).toBe(
`<script src="test.js" type="text/javascript"></script>`,
);
});
test("boolean attribute", () => {
expect(tag("script", {crossorigin: true, src: "test.js"})).toBe(
`<script crossorigin src="test.js"></script>`,
);
});
test("function content", () => {
expect(tag("a", {href: "test.html"}, () => "Click Here")).toBe(
`<a href="test.html">Click Here</a>`,
);
});
test("escape quotes", () => {
expect(tag("span", {title: `"this is a test"`}, "Hello")).toBe(
`<span title=""this is a test"">Hello</span>`,
);
});
test("self-closing tag", () => {
expect(tag("link", {href: "test.css"}, true)).toBe(
`<link href="test.css"/>`,
);
});
});
================================================
FILE: packages/magic/tsconfig.json
================================================
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"module": "none"
},
"include": ["./src"]
}
================================================
FILE: packages/main/CHANGELOG.md
================================================
## 2.1.18 (April 27, 2025)
- fix `Script` type to be readonly + allow narrowing the marker name
## 2.1.17 (February 4, 2025)
- fix ESM exports
## 2.1.15 (January 24, 2025)
- fix `StrictMode` error in `<CaptionsDisplay>`
## 2.1.14 (January 23, 2025)
- make `Player.symbol` indexable via `@liqvid/player/element`
## 2.1.12 (June 12, 2024)
- Strict Mode fixes
## 2.1.10 (June 6, 2024)
- make compatible with Next.js / SSR
## 2.1.9 (November 13, 2022)
- `<Audio>`/`<Video>` will seek to their end when `Playback` is seeked past their end
- `<Audio>`/`<Video>` will restart when `Playback` plays from ended
- `Playback` will fire `stop` and restart correctly when seeked to end (vs played to end)
- `Keymap` no longer throws Errors when calling `unbind()` with an unbound callback
## 2.1.8 (October 29, 2022)
- fix `<Audio>`/`<Video>` pausing playback on end (#31)
## 2.1.7 (May 14, 2022)
- support React 18
## 2.1.6 (May 6, 2022)
- ensure `Player.Context` is always the same even if multiple versions of Liqvid are accidentally loaded
## 2.1.5 (May 4, 2022)
- allow passing numeric durations to `Script` (fixes #26)
## 2.1.4 (March 15, 2022)
- don't crash when `Playback` isn't polyfilled
- update repository URL
## 2.1.3 (March 13, 2022)
- fix `fake-fullscreen` origin
## 2.1.2 (March 13, 2022)
- use Rollup instead of Webpack
- correctly transpile dependencies for old browsers
## 2.1.1 (March 12, 2022)
- fix typings in `package.json`
## 2.1.0 (March 12, 2022)
### New features
- add `Playback.timeline` and `Playback.newAnimation` for much easier animation
- add `Utils.json` and `Utils.svg`
- add `Utils.react.combineRefs`
- put `Utils.animation.bezier` and `Utils.animation.easings` back in
- add `useKeymap`, `usePlayback`, `useScript` hooks
- add `useTime` hook
- distribute as ES module
### Ease-of-use
- `start` prop on `<Audio>` and `<Video>` elements now defaults to `0`
- add defaults to `thumbs` prop
- attach events directly to `Playback` and `Script` instead of `.hub`
- can now use `Player` without `Script`
- add `--lv-canvas-height` CSS variable
- add `data-affords="click"` for cancelling `canvasClick`
### Miscellaneous
- rename library to Liqvid
- expose `Media` class
- expose `ScrubberBar` control
- improve captions support, add captions control
- rename `KeyMap` to `Keymap`
- move most internals to `@liqvid` namespace
## 2.0.10 (Jul 19, 2021)
- add `playsInline` to `<Video>`
## 2.0.9 (Jul 19, 2021)
- fix bug introduced in 2.0.6 where Media ending would pause playback
## 2.0.8 (Jul 19, 2021)
- fix bug where scrubber keys could not be properly reassigned
- fix normalization in Script constructor
## 2.0.7 (Jul 7, 2021)
- package as UMD
## 2.0.6 (Jun 7, 2021)
- work correctly with keyboard play/pause buttons
- make scrubber bar work on desktop touchscreens
- no longer necessary to call `.ready()`, now a noop
- more intelligent canvasClick/keyCapture behavior
- enable captions
## 2.0.5 (May 28, 2021)
- correctly remove listeners when unmounting `<Audio>`/`<Video>`
- remove silly `<Video>` hiding behavior
- add `Script.playback` to typings
## 2.0.4 (May 9, 2021)
- fix bug in `KeyMap.normalize` + mistyping as `KeyMap.canonize`
## 2.0.2/2.0.3 (Jan 22, 2021)
- fix bug in mobile styling
## 2.0.1 (Jan 10, 2021)
- `KeyMap.getHandlers` will return `[]` on unbound sequences instead of throwing error
## 2.0.0 (Dec 31, 2020)
- remove Cursor; use [rp-cursor](https://www.npmjs.com/package/rp-cursor) instead
- rename `Player.$controls` -> `Player.controls`
- remove `Player.CONTROLS_HEIGHT`
- support ordinary events in `Player.preventCanvasClick`
- added `Player.allowScroll`
- added `Script.parseStart` and `Script.parseEnd`
- added `Utils.time.timeRegexp`
- added `Utils.replayData`
- added some documentation
- workaround for https://github.com/facebook/react/issues/2043 affecting Android (now fixed in React v.17)
- added `Utils.react.captureRef`
- added `useMarkerUpdate`, `usePlayer`, `useTimeUpdate` hooks
- added `rp-volume-color` CSS variable
- removed `rememberVolumeSettings` due to cookie laws
- added `KeyMap`
- removed plugin system and "hooks" system (easily confused with React's Hooks); added `Player.props.controls` and `Player.defaultControls*` to replace
- removed `LoadingScreen`
- added `Player.reparseTree`
- allowed `Utils.misc.range` to take two arguments
## 1.1.1 (October 20, 2019)
- fix typings for `utils/animation/replay`
## 1.1.0 (October 20, 2019)
- add `attachClickHandler` in `utils/mobile`
## 1.0.3 (October 20, 2019)
- better mobile scrubbing
- remove external fonts
- work around opacity bug on Safari
## 1.0.2 (September 6, 2019)
- use `StrictEventEmitter` for better typing
## 1.0.1 (September 2, 2019)
- specify `files` correctly in `package.json`
## 1.0.0 (September 2, 2019)
First stable release
## 0.8.0 (November 9, 2018)
Initial public release
================================================
FILE: packages/main/DEVELOPMENT.md
================================================
## Testing
In order for media codecs to work in the e2e tests, Playwright may need your system Chromium instead of its bundled one. To configure this, rename `.env.example` to `.env` and adjust `PLAYWRIGHT_EXECUTABLE_PATH` as necessary.
================================================
FILE: packages/main/README.md
================================================
# liqvid
This is a library for making **interactive** videos in React.
For example, here's an interactive coding demo inside a video:
<a href="https://gfycat.com/frailtemptingeyra"><img src="https://thumbs.gfycat.com/FrailTemptingEyra-size_restricted.gif"/></a>
Here's an interactive graph:
<a href="https://gfycat.com/magnificentdopeybrownbear"><img src="https://thumbs.gfycat.com/MagnificentDopeyBrownbear-size_restricted.gif"/></a>
To get started, go to https://liqvidjs.org/docs/
For inspiration, see https://epiplexis.xyz/
================================================
FILE: packages/main/e2e/app/package.json
================================================
{
"private": true,
"description": "E2E tests for Liqvid",
"main": "index.js",
"scripts": {
"build": "webpack",
"dev": "concurrently \"pnpm watch\" \"pnpm serve\"",
"serve": "serve -p 41728 -S -s static",
"watch": "webpack --watch"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@liqvid/cli": "workspace:^",
"@liqvid/recording": "workspace:^",
"liqvid": "workspace:^"
},
"devDependencies": {
"ts-loader": "^9.3.1",
"typescript": "^4.8.4",
"webpack": "^5.74.0",
"webpack-cli": "^4.10.0"
}
}
================================================
FILE: packages/main/e2e/app/src/index.tsx
================================================
import {createRoot} from "react-dom/client";
import * as Liqvid from "../../../src/index";
import {Playback, Player, Video} from "../../../src/index";
// simplifies testing for now
window.Liqvid = Liqvid;
const playback = new Playback({duration: 60000});
function Lesson() {
return (
<Player playback={playback}>
<Video start={10000}>
<source src={process.env.PLAYWRIGHT_TEST_VIDEO} type="video/mp4" />
</Video>
</Player>
);
}
createRoot(document.querySelector("main")).render(<Lesson />);
================================================
FILE: packages/main/e2e/app/static/index.html
================================================
<!DOCTYPE html>
<html>
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1, maximum-scale=1" />
<link href="./liqvid.min.css" rel="stylesheet" />
<link href="./style.css" rel="stylesheet" />
</head>
<body>
<main></main>
<script src="./bundle.js"></script>
</body>
</html>
================================================
FILE: packages/main/e2e/app/static/style.css
================================================
video {
width: 100%;
}
================================================
FILE: packages/main/e2e/app/tsconfig.json
================================================
{
"compilerOptions": {
"allowJs": true,
"alwaysStrict": true,
"incremental": true,
"jsx": "react-jsx",
"lib": ["es2015", "es2016", "es2017", "dom"],
"moduleResolution": "node",
"pretty": true,
"removeComments": true,
"target": "es2017",
"paths": {
"@env/*": ["./src/@development/*", "./src/@production/*"]
}
},
"files": ["./src/index.tsx"]
}
================================================
FILE: packages/main/e2e/app/webpack.config.js
================================================
const TerserPlugin = require("terser-webpack-plugin");
const path = require("path");
const env = process.env.NODE_ENV || "development";
require("dotenv").config({path: "../../.env"});
const webpack = require("webpack");
module.exports = {
entry: `./src/index.tsx`,
output: {
filename: "bundle.js",
path: path.join(__dirname, "static"),
},
mode: env,
module: {
rules: [
{
test: /\.[jt]sx?$/,
loader: "ts-loader",
},
],
},
plugins: [new webpack.EnvironmentPlugin(["PLAYWRIGHT_TEST_VIDEO"])],
// necessary due to bug in old versions of mobile Safari
devtool: false,
optimization: {
minimizer: [
new TerserPlugin({
parallel: true,
terserOptions: {
safari10: true,
},
}),
],
emitOnErrors: true,
},
resolve: {
extensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
alias: {
"@env": path.join(__dirname, "src", "@" + env),
},
},
};
================================================
FILE: packages/main/e2e/tests/Media.spec.tsx
================================================
import {ElementHandle, expect, JSHandle, test} from "@playwright/test";
import type {Playback, Player} from "../../src/index";
test.describe("Media", () => {
let playback: JSHandle<Playback>;
let player: JSHandle<Player>;
let video: ElementHandle<HTMLVideoElement>;
test.beforeEach(async ({page}) => {
await page.goto("/");
// globals
player = await page.evaluateHandle(() => {
return (document.querySelector(".lv-player") as HTMLDivElement)[
window.Liqvid.Player.symbol
] as Player;
});
playback = await player.evaluateHandle((player) => player.playback);
// load video
const locator = page.locator("video");
await locator.waitFor();
await locator.evaluate<void, HTMLVideoElement>((video) =>
window.Liqvid.Utils.media.awaitMediaCanPlay(video),
);
// create handle
video = (await locator.elementHandle()) as ElementHandle<HTMLVideoElement>;
});
test("seeking past video.duration should seek to video end", async () => {
await playback.evaluate((p) => p.seek(p.duration));
expect(await video.evaluate((v) => v.currentTime === v.duration)).toBe(
true,
);
});
test("restarting playback should restart video", async () => {
await playback.evaluate((p) => {
p.seek(p.duration);
});
expect(await video.evaluate((v) => v.currentTime === v.duration)).toBe(
true,
);
// don't batch with the previous evaluate or else video won't have time to update
await playback.evaluate((p) => p.play());
expect(await video.evaluate((v) => v.currentTime)).toBe(0);
});
});
================================================
FILE: packages/main/jest.config.js
================================================
module.exports = {
preset: "ts-jest",
testEnvironment: "jsdom",
testPathIgnorePatterns: ["dist", "e2e"],
transform: {},
};
================================================
FILE: packages/main/package.json
================================================
{
"name": "liqvid",
"version": "2.1.19",
"description": "Library for playing interactive videos using HTML/CSS/Javascript",
"files": ["dist/*"],
"main": "./dist/liqvid.js",
"module": "./dist/liqvid.mjs",
"exports": {
".": {
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.cjs",
"types": "./dist/types/index.d.ts"
},
"./dist/liqvid.css": "./dist/liqvid.css",
"./dist/liqvid.min.css": "./dist/liqvid.min.css",
"./liqvid.css": "./dist/liqvid.css",
"./liqvid.min.css": "./dist/liqvid.min.css"
},
"scripts": {
"build": "pnpm build:clean && pnpm build:css && pnpm build:js",
"build:clean": "rm -rf dist",
"build:css": "stylus -o dist/liqvid.css styl/liqvid.styl; stylus -c -o dist/liqvid.min.css styl/liqvid.styl",
"build:js": "pnpm build:js:bundle; pnpm build:js:cjs; pnpm build:js:esm; pnpm build:js:fix",
"build:js:bundle": "tsc && rollup -c && rm -rf dist/esm",
"build:js:cjs": "tsc --module commonjs --outDir dist/cjs",
"build:js:esm": "tsc --module esnext --outDir dist/esm",
"build:js:fix": "node ../../build.mjs",
"lint": "eslint --ext ts,tsx --fix e2e src tests",
"stylus": "stylus -o dist/liqvid.css -w styl/liqvid.styl",
"stylus:prod": "stylus -c -o dist/liqvid.min.css -w styl/liqvid.styl",
"test": "pnpm test:jest && pnpm test:build-e2e && pnpm test:playwright",
"test:build-e2e": "cd e2e/app && pnpm build && cd ../..",
"test:jest": "jest",
"test:playwright": "npx playwright test"
},
"repository": {
"type": "git",
"url": "git+https://github.com/liqvidjs/liqvid.git"
},
"author": "Yuri Sulyma <yuri@liqvidjs.org>",
"license": "MIT",
"bugs": {
"url": "https://github.com/liqvidjs/liqvid/issues"
},
"homepage": "https://github.com/liqvidjs/liqvid/tree/master/packages/main#readme",
"devDependencies": {
"nib": "^1.1.2",
"stylus": "^0.57.0",
"tslib": "^2.4.0",
"typedoc": "^0.22.15",
"typedoc-plugin-markdown": "^3.12.1"
},
"dependencies": {
"@liqvid/keymap": "workspace:^",
"@liqvid/playback": "workspace:^",
"@liqvid/utils": "workspace:^",
"@types/events": "^3.0.0",
"@types/node": "^22.10.10",
"events": "^3.3.0",
"strict-event-emitter-types": "^2.0.0"
},
"peerDependencies": {
"@types/react": ">=17",
"@types/react-dom": ">=17",
"react": ">=17",
"react-dom": ">=17"
}
}
================================================
FILE: packages/main/playwright.config.ts
================================================
import dotenv from "dotenv";
dotenv.config();
import type {PlaywrightTestConfig} from "@playwright/test";
const config: PlaywrightTestConfig = {
testDir: "e2e/tests",
use: {
baseURL: process.env.PLAYWRIGHT_HOST,
headless: true,
launchOptions: {
executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH,
},
viewport: {width: 1280, height: 720},
ignoreHTTPSErrors: true,
video: "off",
},
webServer: {
command: "cd e2e/app && pnpm serve",
url: process.env.PLAYWRIGHT_HOST,
reuseExistingServer: !process.env.CI,
},
};
export default config;
================================================
FILE: packages/main/rollup.config.js
================================================
import * as fs from "fs";
import {getBabelOutputPlugin} from "@rollup/plugin-babel";
import commonjs from "@rollup/plugin-commonjs";
import {nodeResolve} from "@rollup/plugin-node-resolve";
import dts from "rollup-plugin-dts";
import {terser} from "rollup-plugin-terser";
// banner
const licenseComment = "/*!" + fs.readFileSync("./LICENSE", "utf8") + "*/";
const useClientDirective = '"use client";';
const banner = `${licenseComment}\n${useClientDirective}`;
/* shared UMD config --- don't put plugins here bc array will get copied by reference */
const umdConfig = {
banner,
format: "esm",
globals: {
react: "React",
"react-dom": "ReactDOM",
},
};
// babel config
const babelConfig = () =>
getBabelOutputPlugin({
plugins: [
[
"@babel/plugin-transform-modules-umd",
{
globals: {
react: "React",
"react-dom": "ReactDOM",
},
moduleId: "Liqvid",
moduleRoot: "Liqvid",
},
],
],
presets: [["@babel/env", {targets: {ios: "12"}}]],
});
export default [
{
external: ["react", "react-dom"],
input: "dist/esm/index.js",
plugins: [nodeResolve({preferBuiltins: false}), commonjs()],
output: [
// ESM
{
banner,
file: "./dist/liqvid.mjs",
format: "esm",
},
// UMD development
{
...umdConfig,
file: "./dist/liqvid.js",
plugins: [babelConfig()],
},
// UMD production
{
...umdConfig,
file: "./dist/liqvid.min.js",
plugins: [babelConfig(), terser({module: false, safari10: true})],
},
],
},
// types
{
input: "dist/types/index.d.ts",
plugins: [dts()],
output: {
file: "dist/liqvid.d.ts",
format: "es",
},
},
];
================================================
FILE: packages/main/src/Audio.tsx
================================================
import * as React from "react";
import {Media} from "./Media";
import {fragmentFromHTML} from "./utils/dom";
/** Liqvid equivalent of {@link HTMLAudioElement `<audio>`}. */
export class Audio extends Media {
/** The underlying <audio> element. */
declare domElement: HTMLAudioElement;
componentDidMount() {
super.componentDidMount();
// tracks
for (const track of Array.from(this.domElement.textTracks)) {
if (!["captions", "subtitles"].includes(track.kind)) continue;
let mode = track.mode;
track.addEventListener("cuechange", () => {
if (track.mode !== "showing") {
if (mode === "showing") this.playback.captions = [];
mode = track.mode;
return;
}
mode = track.mode;
const captions = [];
for (const cue of Array.from(track.activeCues)) {
// @ts-expect-error check this I guess
const html = cue.text.replace(/\n/g, "<br/>");
captions.push(fragmentFromHTML(html));
}
this.playback.captions = captions;
});
}
}
// render method
render() {
const {start, obstructCanPlay, obstructCanPlayThrough, children, ...attrs} =
this.props;
return (
<audio preload="auto" ref={(node) => (this.domElement = node)} {...attrs}>
{children}
</audio>
);
}
}
================================================
FILE: packages/main/src/CaptionsDisplay.tsx
================================================
import * as React from "react";
import {useEffect, useRef} from "react";
import {usePlayback} from "@liqvid/playback/react";
export default function Captions() {
const playback = usePlayback();
const domElement = useRef<HTMLDivElement>();
useEffect(() => {
const updateCaptions = () => {
domElement.current.innerHTML = "";
for (const cue of playback.captions) {
domElement.current.appendChild(cue);
}
};
playback.on("cuechange", updateCaptions);
return () => {
playback.off("cuechange", updateCaptions);
};
}, [playback]);
return <div className="lv-captions-display" ref={domElement} />;
}
================================================
FILE: packages/main/src/Controls.tsx
================================================
import * as React from "react";
import {useCallback, useEffect, useRef, useState} from "react";
import {ScrubberBar, ThumbData} from "./controls/ScrubberBar";
import {useKeymap} from "@liqvid/keymap/react";
import {usePlayback} from "@liqvid/playback/react";
import {Player} from "./Player";
interface Props {
controls: JSX.Element | JSX.Element[];
thumbs?: ThumbData;
}
// hiding timeout
const TIMEOUT = 3000;
export default function Controls(props: Props) {
const keymap = useKeymap();
const playback = usePlayback();
const [visible, setVisible] = useState(true);
const timer = useRef(0);
// reset the hiding timer
const resetTimer = useCallback(() => {
if (playback.paused) return;
if (timer.current !== undefined) clearTimeout(timer.current);
timer.current = window.setTimeout(() => setVisible(false), TIMEOUT);
setVisible(true);
}, [playback]);
// mount subscriptions
useEffect(() => {
// hide on keyboard input
keymap.bind("*", resetTimer);
// show/hiding
document.body.addEventListener("touchstart", resetTimer);
document.body.addEventListener("mousemove", resetTimer);
playback.on("play", resetTimer);
playback.on("pause", () => {
clearTimeout(timer.current);
setVisible(true);
});
playback.on("stop", () => {
clearTimeout(timer.current);
setVisible(true);
});
document.body.addEventListener("mouseleave", () => {
if (playback.paused) return;
setVisible(false);
});
}, [keymap, playback, resetTimer]);
const classNames = ["rp-controls", "lv-controls"];
if (!visible) classNames.push("hidden");
return (
<div className={classNames.join(" ")}>
<ScrubberBar thumbs={props.thumbs} />
<div className="lv-controls-buttons">
{props.controls instanceof Array ? (
<>
{Player.defaultControlsLeft}
<div className="lv-controls-right">
{...props.controls}
{Player.defaultControlsRight}
</div>
</>
) : (
props.controls
)}
</div>
</div>
);
}
================================================
FILE: packages/main/src/IdMap.tsx
================================================
import * as React from "react";
import {bind} from "@liqvid/utils/misc";
import {recursiveMap} from "@liqvid/utils/react";
interface Props {
children?: React.ReactNode;
map?: Record<string, unknown>;
}
/**
* This class gives a way to automagically attach data loaded from a file as attributes on elements.
* This is provided to facilitate the development of—and provide a standard interface for—GUI tools.
*/
export class IdMap extends React.PureComponent<Props> {
static Context = React.createContext([]);
/** IDs found within the IdMap */
foundIds: Set<string>;
constructor(props: Props) {
super(props);
bind(this, ["renderContent"]);
this.foundIds = new Set();
}
render() {
if (this.props.hasOwnProperty("map")) {
return (
<IdMap.Context.Provider value={[this.foundIds, this.props.map]}>
{this.renderContent([this.foundIds, this.props.map])}
</IdMap.Context.Provider>
);
} else {
return (
<IdMap.Context.Consumer>{this.renderContent}</IdMap.Context.Consumer>
);
}
}
renderContent([foundIds, map]: [Set<string>, unknown]) {
return recursiveMap(this.props.children, (node) => {
const attrs = {};
if (node.props.hasOwnProperty("id")) {
const {id} = (node as React.ReactElement<{id: string}>).props;
foundIds.add(id);
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
if ((map as any)[id] !== undefined)
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
Object.assign(attrs, (map as any)[id]);
}
if (Object.keys(attrs).length === 0) {
return node;
} else {
return React.cloneElement(node, attrs);
}
});
}
}
================================================
FILE: packages/main/src/Media.ts
================================================
import * as React from "react";
import {awaitMediaCanPlay, awaitMediaCanPlayThrough} from "./utils/media";
import {between, bind} from "@liqvid/utils/misc";
import type {Playback} from "@liqvid/playback";
import {Player} from "./Player";
interface Props extends React.HTMLAttributes<HTMLMediaElement> {
obstructCanPlay?: boolean;
obstructCanPlayThrough?: boolean;
start?: number;
}
export class Media extends React.PureComponent<
Props,
Record<string, never>,
Player
> {
protected playback: Playback;
protected player: Player;
protected domElement: HTMLMediaElement;
/** When the media element should start playing. */
start: number;
static defaultProps = {
obstructCanPlay: false,
obstructCanPlayThrough: false,
};
static contextType = Player.Context;
constructor(props: Props, context: Player) {
super(props, context);
this.player = context;
this.playback = context.playback;
// get the time right
this.start = this.props.start ?? 0;
bind(this, [
"pause",
"play",
"onPlay",
"onRateChange",
"onSeek",
"onTimeUpdate",
"onVolumeChange",
"onDomPlay",
"onDomPause",
]);
}
componentDidMount() {
// attach event listeners
this.playback.on("pause", this.pause);
this.playback.on("play", this.onPlay);
this.playback.on("ratechange", this.onRateChange);
this.playback.on("seek", this.onSeek);
this.playback.on("seeking", this.pause);
this.playback.on("timeupdate", this.onTimeUpdate);
this.playback.on("volumechange", this.onVolumeChange);
this.domElement.addEventListener("play", this.onDomPlay);
this.domElement.addEventListener("pause", this.onDomPause);
// canplay/canplaythrough events
if (this.props.obstructCanPlay) {
this.player.obstruct("canplay", awaitMediaCanPlay(this.domElement));
}
if (this.props.obstructCanPlayThrough) {
this.player.obstruct(
"canplaythrough",
awaitMediaCanPlayThrough(this.domElement),
);
}
// need to call this once initially
this.onVolumeChange();
// progress updater?
/*const getBuffers = () => {
const ranges = this.domElement.buffered;
const buffers: [number, number][] = [];
for (let i = 0; i < ranges.length; ++i) {
if (ranges.end(i) === Infinity) continue;
buffers.push([ranges.start(i) * 1000 + this.start, ranges.end(i) * 1000 + this.start]);
}
return buffers;
};
const updateBuffers = () => {
this.player.updateBuffer(this.domElement, getBuffers());
};
this.player.registerBuffer(this.domElement);
updateBuffers();
this.domElement.addEventListener("progress", updateBuffers);
// setInterval(updateBuffers, 1000);
// this.domElement.addEventListener('load', updateBuffers);
*/
}
componentWillUnmount() {
this.playback.off("pause", this.pause);
this.playback.off("play", this.onPlay);
this.playback.off("ratechange", this.onRateChange);
this.playback.off("seek", this.onSeek);
this.playback.off("seeking", this.pause);
this.playback.off("timeupdate", this.onTimeUpdate);
this.playback.off("volumechange", this.onVolumeChange);
this.domElement.removeEventListener("pause", this.onDomPause);
this.domElement.removeEventListener("play", this.onDomPlay);
// this.player.unregisterBuffer(this.domElement);
}
// getter
get end(): number {
return this.start + this.domElement.duration * 1000;
}
pause(): void {
if (!this.domElement.ended) {
this.domElement.removeEventListener("pause", this.onDomPause);
this.domElement.pause();
this.domElement.addEventListener("pause", this.onDomPause);
}
}
play(): Promise<void> {
this.domElement.removeEventListener("play", this.onDomPlay);
const promise = this.domElement.play();
this.domElement.addEventListener("play", this.onDomPlay);
return promise;
}
onPlay(): void {
this.onTimeUpdate(this.playback.currentTime);
}
onRateChange(): void {
this.domElement.playbackRate = this.playback.playbackRate;
}
onSeek(t: number): void {
this.domElement.currentTime = (t - this.start) / 1000;
if (between(this.start, t, this.end)) {
if (
this.domElement.paused &&
!this.playback.paused &&
!this.playback.seeking
) {
this.play().catch(this.playback.pause);
}
} else {
if (!this.domElement.paused) this.pause();
}
}
onTimeUpdate(t: number): void {
if (between(this.start, t, this.end)) {
if (!this.domElement.paused) return;
this.domElement.currentTime = (t - this.start) / 1000;
this.play().catch(this.playback.pause);
} else {
if (!this.domElement.paused) this.pause();
this.domElement.currentTime = (t - this.start) / 1000;
}
}
onVolumeChange(): void {
this.domElement.volume = this.playback.volume;
this.domElement.muted = this.playback.muted;
}
onDomPlay(): void {
if (this.playback.paused) {
this.playback.off("play", this.onPlay);
this.playback.play();
this.playback.on("play", this.onPlay);
}
}
onDomPause(): void {
if (
!this.playback.seeking &&
!this.playback.paused &&
!hasEnded(this.domElement)
) {
this.playback.off("pause", this.pause);
this.playback.pause();
this.playback.on("pause", this.pause);
}
}
}
/**
* Guess whether a media element has ended.
* (`paused` fires before `ended`, and `currentTime` may be >100ms
* behind `duration` when this happens).
* @param media Media element to check.
* @param threshold How far from the end of the media should be considered "ended".
* @returns Whether the media element has reached its end.
*/
function hasEnded(media: HTMLMediaElement, threshold = 0.5): boolean {
return media.ended || media.duration - media.currentTime < threshold;
}
================================================
FILE: packages/main/src/Player.tsx
================================================
import * as React from "react";
import {EventEmitter} from "events";
import type StrictEventEmitter from "strict-event-emitter-types";
import CaptionsDisplay from "./CaptionsDisplay";
import {Keymap} from "@liqvid/keymap";
import {Playback} from "./playback";
import {PlaybackContext} from "@liqvid/playback/react";
import {KeymapContext} from "@liqvid/keymap/react";
import {Script} from "./script";
import Controls from "./Controls";
import {Captions} from "./controls/Captions";
import {FullScreen} from "./controls/FullScreen";
import {PlayPause} from "./controls/PlayPause";
import type {ThumbData} from "./controls/ScrubberBar";
import {Settings} from "./controls/Settings";
import {TimeDisplay} from "./controls/TimeDisplay";
import {Volume} from "./controls/Volume";
import {bind} from "@liqvid/utils/misc";
import {anyHover} from "@liqvid/utils/interaction";
import {createUniqueContext} from "@liqvid/utils/react";
interface PlayerEvents {
canplay: void;
canplaythrough: void;
canvasClick: void;
}
interface Props extends React.HTMLAttributes<HTMLDivElement> {
controls?: JSX.Element | JSX.Element[];
playback?: Playback;
script?: Script;
thumbs?: ThumbData;
}
const allowScroll = Symbol();
const ignoreCanvasClick = Symbol();
export class Player extends React.PureComponent<Props> {
/**
* Liqvid analogue of the [`canplay`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/canplay_event) event.
* This can be used to wait for Audio or Video files to load. You can also use {@link obstruct} to add custom loaders.
*/
canPlay: Promise<void>;
/**
* Liqvid analogue of the [`canplaythrough`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/canplaythrough_event) event.
* This can be used to wait for Audio or Video files to load. You can also use {@link obstruct} to add custom loaders.
*/
canPlayThrough: Promise<void>;
/** The {@link HTMLDivElement `<div>`} where content is attached (separate from controls). */
canvas: HTMLDivElement;
/** Whether keyboard controls are currently being handled. */
captureKeys: boolean;
hub: StrictEventEmitter<EventEmitter, PlayerEvents>;
/** {@link Keymap} attached to the player */
keymap: Keymap;
/** {@link Playback} attached to the player */
playback: Playback;
/** {@link Script} attached to the player */
script: Script;
buffers: Map<HTMLMediaElement, [number, number][]>;
private __canPlayTasks: Promise<unknown>[];
private __canPlayThroughTasks: Promise<unknown>[];
private dag: DAGLeaf;
/** {@link React.Context} used to access ambient Player */
static Context = createUniqueContext<Player>("@liqvid/player", null);
/**
* Symbol to access the {@link Player} instance attached to a DOM element
*
* `player.canvas.parentElement[Player.symbol] === player`
*/
static symbol = Symbol.for("@liqvid/player/element");
/** Default controls appearing on the left */
static defaultControlsLeft = (
<>
<PlayPause />
<Volume />
<TimeDisplay />
</>
);
/** Default controls appearing on the right */
static defaultControlsRight = (
<>
<Captions />
<Settings />
<FullScreen />
</>
);
static defaultProps = {
controls: (
<>
{Player.defaultControlsLeft}
<div className="lv-controls-right">{Player.defaultControlsRight}</div>
</>
),
style: {},
};
constructor(props: Props) {
super(props);
this.hub = new EventEmitter() as StrictEventEmitter<
EventEmitter,
PlayerEvents
>;
this.__canPlayTasks = [];
this.__canPlayThroughTasks = [];
this.keymap = new Keymap();
this.captureKeys = true;
if (props.script) {
this.script = props.script;
this.playback = this.script.playback;
} else {
this.playback = props.playback;
}
this.buffers = new Map();
bind(this, [
"onMouseUp",
"suspendKeyCapture",
"resumeKeyCapture",
"reparseTree",
]);
this.updateTree = this.updateTree.bind(this);
}
componentDidMount() {
const element = this.canvas.parentElement;
// biome-ignore lint/suspicious/noExplicitAny: symbol
(element as any)[Player.symbol] = this;
// inline or frame?
// const client =
// element.parentElement.nodeName.toLowerCase() === "main" &&
// element.parentElement.parentElement === document.body &&
// element.parentElement.childNodes.length === 1;
// document.documentElement.classList.toggle("lv-frame", client);
// element.classList.toggle("lv-frame", client);
// keyboard events
document.body.addEventListener("keydown", (e) => {
if (!this.captureKeys || document.activeElement !== document.body) return;
this.keymap.handle(e);
});
// prevent scroll on mobile
// document.addEventListener("touchmove", e => {
// if (e[allowScroll]) return;
// e.preventDefault();
// }, {passive: false});
// document.addEventListener("touchforcechange", e => e.preventDefault(), {passive: false});
// canPlay events --- mostly unused
this.canPlay = Promise.all(this.__canPlayTasks).then(() => {
this.hub.emit("canplay");
});
this.canPlayThrough = Promise.all(this.__canPlayThroughTasks).then(() => {
this.hub.emit("canplaythrough");
});
// hiding stuff
if (this.script) {
this.dag = toposort(this.canvas, this.script.markerNumberOf);
this.script.on("markerupdate", this.updateTree);
this.updateTree();
}
}
private updateTree(): void {
const {script} = this;
recurse(this.dag);
/** Hide element */
function hide(leaf: DAGLeaf): void {
leaf.element.style.opacity = "0";
leaf.element.style.pointerEvents = "none";
leaf.element.setAttribute("aria-hidden", "true");
}
/** Show element */
function show(leaf: DAGLeaf): void {
leaf.element.style.removeProperty("opacity");
leaf.element.style.removeProperty("pointer-events");
leaf.element.removeAttribute("aria-hidden");
return leaf.children.forEach(recurse);
}
/** Recurse through DAG */
function recurse(leaf: DAGLeaf): void {
if (typeof leaf.first !== "undefined") {
if (
leaf.first <= script.markerIndex &&
(!leaf.last || script.markerIndex < leaf.last)
) {
return show(leaf);
}
hide(leaf);
} else if (typeof leaf.during !== "undefined") {
if (script.markerName.startsWith(leaf.during)) {
return show(leaf);
}
return hide(leaf);
} else {
return leaf.children.forEach(recurse);
}
}
}
private canvasClick(): void {
const allow = this.hub.listeners("canvasClick").every((_) => _() ?? true);
if (allow) {
this.playback.paused ? this.playback.play() : this.playback.pause();
}
this.hub.emit("canvasClick");
}
onMouseUp(e: React.MouseEvent<HTMLDivElement>): void {
// ignore clicks on input tags
if (
["a", "area", "button", "input", "option", "select", "textarea"].includes(
(e.target as Element).nodeName.toLowerCase(),
)
)
return;
// data-affords markup
if ((e.target as Element)?.closest(`*[data-affords~="click"]`)) {
return;
}
// the reason for this escape hatch is that this gets called in between an element's onMouseUp
// listener and the listener added by dragHelper, so you can't call stopPropagation() in the
// onMouseUp or else the dragging won't release.
// biome-ignore lint/suspicious/noExplicitAny: symbol
if ((e.nativeEvent as any)[ignoreCanvasClick]) return;
this.canvasClick();
}
static allowScroll(e: React.TouchEvent | TouchEvent): void {
// biome-ignore lint/suspicious/noExplicitAny: symbol
(("nativeEvent" in e ? e.nativeEvent : e) as any)[allowScroll] = true;
}
/**
* Prevent canvas clicks from pausing the video.
* @param e Click event on video canvas
* @deprecated Use data-affords="click" instead
*/
static preventCanvasClick(e: MouseEvent | React.MouseEvent): void {
// biome-ignore lint/suspicious/noExplicitAny: symbol
(("nativeEvent" in e ? e.nativeEvent : e) as any)[ignoreCanvasClick] = true;
}
/** Suspends keyboard controls so that components can receive keyboard input. */
suspendKeyCapture(): void {
this.captureKeys = false;
}
/** Resumes keyboard controls. */
resumeKeyCapture(): void {
this.captureKeys = true;
}
/** @deprecated */
ready(): void {
console.info(".ready() is a noop in v2.1");
}
/**
* Reparse a section of the document for `during()` and `from()`
* @param node Element to reparse
*/
reparseTree(node: HTMLElement | SVGElement): void {
const root = findClosest(node, this.dag);
if (!root) {
throw new Error("Could not find node in tree");
}
root.children = toposort(root.element, this.script.markerNumberOf).children;
this.updateTree();
}
registerBuffer(elt: HTMLMediaElement): void {
this.buffers.set(elt, []);
}
unregisterBuffer(elt: HTMLMediaElement): void {
this.buffers.delete(elt);
}
updateBuffer(elt: HTMLMediaElement, buffers: [number, number][]): void {
this.buffers.set(elt, buffers);
this.playback.emit("bufferupdate");
}
/**
* Obstruct {@link canPlay} or {@link canPlayThrough} events
* @param event Which event type to obstruct
* @param task Promise to append
*/
obstruct(event: "canplay" | "canplaythrough", task: Promise<unknown>): void {
if (event === "canplay") {
this.__canPlayTasks.push(task);
} else {
this.__canPlayThroughTasks.push(task);
}
}
render() {
const attrs = {
style: this.props.style,
};
const canvasAttrs = anyHover ? {onMouseUp: this.onMouseUp} : {};
const classNames = ["lv-player", "ractive-player"];
return (
<Player.Context.Provider value={this}>
<PlaybackContext.Provider value={this.playback}>
<KeymapContext.Provider value={this.keymap}>
<div className={classNames.join(" ")} {...attrs}>
<div
className="rp-canvas lv-canvas"
{...canvasAttrs}
ref={(canvas) => (this.canvas = canvas)}
>
{this.props.children}
</div>
<CaptionsDisplay />
<Controls
controls={this.props.controls}
thumbs={this.props.thumbs}
/>
</div>
</KeymapContext.Provider>
</PlaybackContext.Provider>
</Player.Context.Provider>
);
}
}
interface DAGLeaf {
children: DAGLeaf[];
element: HTMLElement | SVGElement;
during?: string;
first?: number;
last?: number;
}
/* topological sort */
function toposort(
root: HTMLElement | SVGElement,
mn: (markerName: string) => number,
): DAGLeaf {
const nodes = Array.from(
root.querySelectorAll("*[data-from-first], *[data-during]"),
) as (HTMLElement | SVGElement)[];
const dag: DAGLeaf = {children: [], element: root};
const path: DAGLeaf[] = [dag];
for (const node of nodes) {
// get first and last marker
let firstMarkerName, lastMarkerName, during;
if (node.dataset.fromFirst) {
firstMarkerName = node.dataset.fromFirst;
lastMarkerName = node.dataset.fromLast;
} else if (node.dataset.during) {
during = node.dataset.during;
}
// CSS hides this initially, take over now
node.style.opacity = "0";
node.style.pointerEvents = "none";
// node.removeAttribute("data-from-first");
// node.removeAttribute("data-from-last");
// node.removeAttribute("data-from-during");
// build the leaf
const leaf: DAGLeaf = {
children: [],
element: node,
};
if (during) leaf.during = during;
if (firstMarkerName) leaf.first = mn(firstMarkerName);
if (lastMarkerName) leaf.last = mn(lastMarkerName);
// figure out where to graft it
let current = path[path.length - 1];
while (!current.element.contains(node)) {
path.pop();
current = path[path.length - 1];
}
current.children.push(leaf);
path.push(leaf);
}
return dag;
}
/**
* Find element's closest ancestor in DAG
* @param needle Element to find
* @param haystack DAG leaf to search
* @returns Closest ancestor
*/
function findClosest(
needle: HTMLElement | SVGElement,
haystack: DAGLeaf,
): DAGLeaf {
if (!haystack.element.contains(needle)) {
return null;
}
for (let i = 0; i < haystack.children.length; ++i) {
if (haystack.children[i].element.contains(needle)) {
return findClosest(needle, haystack.children[i]) ?? haystack;
}
}
return haystack;
}
================================================
FILE: packages/main/src/Video.tsx
================================================
import * as React from "react";
import {Media} from "./Media";
/** Liqvid equivalent of {@link HTMLVideoElement `<video>`}. */
export class Video extends Media {
/** The underlying <video> element. */
declare domElement: HTMLVideoElement;
// render method
render() {
const {start, children, obstructCanPlay, obstructCanPlayThrough, ...attrs} =
this.props;
return (
<video
playsInline
preload="auto"
ref={(node) => (this.domElement = node)}
{...attrs}
>
{children}
</video>
);
}
}
================================================
FILE: packages/main/src/controls/Captions.tsx
================================================
import {onClick} from "@liqvid/utils/react";
import * as React from "react";
import {useCallback, useEffect, useMemo, useState} from "react";
import {useKeymap, usePlayer} from "../hooks";
/** Captions control. */
export function Captions() {
const player = usePlayer();
const keymap = useKeymap();
const [visible, setVisible] = useState(false);
const toggleCaptions = useCallback(
(
e:
| KeyboardEvent
| React.MouseEvent<HTMLButtonElement>
| React.TouchEvent<HTMLButtonElement>,
) => {
player.canvas.parentElement.classList.toggle("lv-captions");
// blur or keyboard controls will get snagged
if (e.currentTarget instanceof HTMLButtonElement) e.currentTarget.blur();
},
// note that player.canvas may not have loaded yet
[player.canvas],
);
useEffect(() => {
// visibility
setVisible(!!player.canvas.querySelector("track"));
// keyboard shortcut
keymap.bind("C", toggleCaptions);
return () => {
keymap.unbind("C", toggleCaptions);
};
}, [keymap, player.canvas, toggleCaptions]);
const events = useMemo(() => onClick(toggleCaptions), [toggleCaptions]);
const style: React.CSSProperties = useMemo(
() => (visible ? {} : {display: "none"}),
[visible],
);
return (
<button
className="lv-controls-captions"
{...events}
{...{style}}
title="Captions (c)"
>
<svg viewBox="0 0 36 36">
<path d="M 6.00014 8.00002 C 4.33815 8.00002 2.99981 8.8919 2.99981 9.99989 L 2.99981 25.9999 C 2.99981 27.1079 4.33815 27.9998 6.00014 27.9998 L 30.0002 27.9998 C 31.6622 27.9998 33 27.1079 33 25.9999 L 33 9.99989 C 33 8.8919 31.6622 8.00002 30.0002 8.00002 L 6.00014 8.00002 Z M 14.4032 14.0389 C 15.33 14.0389 16.0827 14.3128 16.6615 14.8606 C 17.006 15.1844 17.2644 15.6495 17.4366 16.2558 L 15.9225 16.6176 C 15.833 16.2248 15.6452 15.9148 15.3592 15.6874 C 15.0768 15.46 14.7322 15.3463 14.3257 15.3463 C 13.7642 15.3463 13.3077 15.5479 12.9563 15.9509 C 12.6083 16.354 12.4344 17.0069 12.4344 17.9095 C 12.4344 18.8672 12.6066 19.5493 12.9511 19.9559 C 13.2956 20.3624 13.7435 20.5656 14.2947 20.5656 C 14.7012 20.5656 15.0509 20.4365 15.3437 20.1781 C 15.6366 19.9197 15.8467 19.5132 15.9742 18.9585 L 17.4573 19.4288 C 17.2299 20.2556 16.851 20.8705 16.3204 21.2736 C 15.7933 21.6732 15.1233 21.8731 14.3102 21.8731 C 13.3043 21.8731 12.4774 21.5303 11.8298 20.8447 C 11.1821 20.1557 10.8582 19.2152 10.8582 18.0232 C 10.8582 16.7623 11.1838 15.7839 11.8349 15.0879 C 12.486 14.3886 13.3422 14.0389 14.4032 14.0389 Z M 22.0462 14.0389 C 22.9729 14.0389 23.7257 14.3128 24.3044 14.8606 C 24.6489 15.1844 24.9073 15.6495 25.0796 16.2558 L 23.5655 16.6176 C 23.4759 16.2248 23.2881 15.9148 23.0022 15.6874 C 22.7197 15.46 22.3752 15.3463 21.9687 15.3463 C 21.4071 15.3463 20.9506 15.5479 20.5992 15.9509 C 20.2513 16.354 20.0773 17.0069 20.0773 17.9095 C 20.0773 18.8672 20.2496 19.5493 20.5941 19.9559 C 20.9386 20.3624 21.3864 20.5656 21.9377 20.5656 C 22.3442 20.5656 22.6938 20.4365 22.9867 20.1781 C 23.2795 19.9197 23.4897 19.5132 23.6171 18.9585 L 25.1002 19.4288 C 24.8729 20.2556 24.4939 20.8705 23.9634 21.2736 C 23.4363 21.6732 22.7662 21.8731 21.9532 21.8731 C 20.9472 21.8731 20.1204 21.5303 19.4727 20.8447 C 18.825 20.1557 18.5012 19.2152 18.5012 18.0232 C 18.5012 16.7623 18.8267 15.7839 19.4779 15.0879 C 20.129 14.3886 20.9851 14.0389 22.0462 14.0389 Z" />
</svg>
</button>
);
}
================================================
FILE: packages/main/src/controls/FullScreen.tsx
================================================
import * as React from "react";
import {useEffect} from "react";
import {
exitFullScreen,
isFullScreen,
onFullScreenChange,
requestFullScreen,
} from "../fake-fullscreen";
import {strings} from "../i18n";
import {onClick, useForceUpdate} from "@liqvid/utils/react";
import {useKeymap} from "@liqvid/keymap/react";
const toggleFullScreen = () =>
isFullScreen() ? exitFullScreen() : requestFullScreen();
const events = onClick(toggleFullScreen);
/** Fullscreen control */
export function FullScreen() {
const keymap = useKeymap();
const forceUpdate = useForceUpdate();
useEffect(() => {
// listener
onFullScreenChange(forceUpdate);
// keyboard shortcut
keymap.bind("F", toggleFullScreen);
return () => {
keymap.unbind("F", toggleFullScreen);
};
}, [forceUpdate, keymap]);
const full = isFullScreen();
const label =
(full ? strings.EXIT_FULL_SCREEN : strings.ENTER_FULL_SCREEN) + " (f)";
return (
<button
className="lv-controls-fullscreen"
aria-label={label}
title={label}
{...events}
>
<svg viewBox="0 0 36 36">
{full ? exitFullScreenIcon : enterFullScreenIcon}
</svg>
</button>
);
}
/** Icon to exit full screen */
const exitFullScreenIcon = (
<>
<path fill="white" d="M 14 14 h -4 v 2 h 6 v -6 h -2 v 4 z" />
<path fill="white" d="M 22 14 v -4 h -2 v 6 h 6 v -2 h -4 z" />
<path fill="white" d="M 20 26 h 2 v -4 h 4 v -2 h -6 v 6 z" />
<path fill="white" d="M 10 22 h 4 v 4 h 2 v -6 h -6 v 2 z" />
</>
);
/** Icon to enter full screen */
const enterFullScreenIcon = (
<>
<path fill="white" d="M 10 16 h 2 v -4 h 4 v -2 h -6 v 6 z" />
<path fill="white" d="M 20 10 v 2 h 4 v 4 h 2 v -6 h -6 z" />
<path fill="white" d="M 24 24 h -4 v 2 h 6 v -6 h -2 v 4 z" />
<path fill="white" d="M 12 20 h -2 v 6 h 6 v -2 h -4 v -4 z" />
</>
);
================================================
FILE: packages/main/src/controls/PlayPause.tsx
================================================
import {useKeymap} from "@liqvid/keymap/react";
import {usePlayback} from "@liqvid/playback/react";
import {onClick, useForceUpdate} from "@liqvid/utils/react";
import * as React from "react";
import {useEffect, useMemo} from "react";
import {strings} from "../i18n";
/** Control for playing/pausing */
export function PlayPause() {
const keymap = useKeymap();
const playback = usePlayback();
const forceUpdate = useForceUpdate();
useEffect(() => {
// subscribe to events
const events = ["pause", "play", "seeking", "seeked", "stop"] as const;
for (const e of events)
playback.on(e, () => {
forceUpdate();
});
// keyboard controls
const toggle = () => playback[playback.paused ? "play" : "pause"]();
keymap.bind("K", toggle);
keymap.bind("Space", () => {
toggle();
});
return () => {
// unbind playback listeners
for (const e of events) playback.off(e, forceUpdate);
// unbind keyboard controls
keymap.unbind("K", toggle);
keymap.unbind("Space", toggle);
};
}, [forceUpdate, keymap, playback]);
// event handler
const events = useMemo(
() => onClick(() => (playback.paused ? playback.play() : playback.pause())),
[playback],
);
const label =
(playback.paused || playback.seeking ? strings.PLAY : strings.PAUSE) +
" (k)";
return (
<button
className="lv-controls-playpause"
aria-label={label}
title={label}
{...events}
>
<svg viewBox="0 0 36 36">
{playback.paused || playback.seeking ? playIcon : pauseIcon}
</svg>
</button>
);
}
/** Play icon */
const playIcon = (
<path
d="M 12,26 18.5,22 18.5,14 12,10 z M 18.5,22 25,18 25,18 18.5,14 z"
fill="white"
/>
);
/** Pause icon */
const pauseIcon = (
<path d="M 12 26 h 4 v -16 h -4 z M 21 26 h 4 v -16 h -4 z" fill="white" />
);
================================================
FILE: packages/main/src/controls/ScrubberBar.tsx
================================================
import * as React from "react";
import {useCallback, useEffect, useMemo, useRef, useState} from "react";
import {ThumbData, ThumbnailBox} from "./ThumbnailBox";
import {anyHover, onDrag} from "@liqvid/utils/interaction";
import {between, clamp} from "@liqvid/utils/misc";
import {captureRef} from "@liqvid/utils/react";
import {useKeymap, usePlayback, useScript} from "../hooks";
export {ThumbData};
export function ScrubberBar(props: {thumbs: ThumbData}) {
const keymap = useKeymap();
const playback = usePlayback();
const script = useScript();
const [progress, setProgress] = useState({
scrubber: playback.currentTime / playback.duration,
thumb: playback.currentTime / playback.duration,
});
const [showThumb, setShowThumb] = useState(false);
// refs
const scrubberBar = useRef<HTMLDivElement>();
/* Event handlers */
const seek = useCallback(() => {
if (playback.seeking) return;
const progress = playback.currentTime / playback.duration;
setProgress({scrubber: progress, thumb: progress});
}, [playback]);
const seeked = useCallback(() => {
const progress = playback.currentTime / playback.duration;
setProgress((prev) => ({scrubber: progress, thumb: prev.thumb}));
}, [playback]);
const timeupdate = useCallback(() => {
const progress = playback.currentTime / playback.duration;
setProgress((prev) => ({scrubber: progress, thumb: prev.thumb}));
}, [playback]);
const back5 = useCallback(
() => playback.seek(playback.currentTime - 5000),
[playback],
);
const fwd5 = useCallback(
() => playback.seek(playback.currentTime + 5000),
[playback],
);
const back10 = useCallback(
() => playback.seek(playback.currentTime - 10000),
[playback],
);
const fwd10 = useCallback(
() => playback.seek(playback.currentTime + 10000),
[playback],
);
const seekPercent = useCallback(
(e: KeyboardEvent) => {
const num = parseInt(e.key, 10);
if (!isNaN(num)) {
playback.seek((playback.duration * num) / 10);
}
},
[playback],
);
useEffect(() => {
/* playback listeners */
playback.on("seek", seek);
playback.on("seeked", seeked);
playback.on("timeupdate", timeupdate);
/* keyboard shortcuts */
// seek 5
keymap.bind("ArrowLeft", back5);
keymap.bind("ArrowRight", fwd5);
// seek 10
keymap.bind("J", back10);
keymap.bind("L", fwd10);
// percentage seeking
keymap.bind("0,1,2,3,4,5,6,7,8,9", seekPercent);
// seek by marker
if (script) {
keymap.bind("W", script.back);
keymap.bind("E", script.forward);
}
return () => {
playback.off("seek", seek);
playback.off("seeked", seeked);
playback.off("timeupdate", timeupdate);
keymap.unbind("ArrowLeft", back5);
keymap.unbind("ArrowRight", fwd5);
keymap.unbind("J", back10);
keymap.unbind("L", fwd10);
keymap.unbind("0,1,2,3,4,5,6,7,8,9", seekPercent);
if (script) {
keymap.unbind("W", script.back);
keymap.unbind("E", script.forward);
}
};
}, [
back10,
back5,
fwd10,
fwd5,
keymap,
playback,
script,
seek,
seekPercent,
seeked,
timeupdate,
]);
// event handlers
const divEvents = useMemo(() => {
if (!anyHover) return {};
const listener = onDrag(
// move
(e, {x}) => {
const rect = scrubberBar.current.getBoundingClientRect(),
progress = clamp(0, (x - rect.left) / rect.width, 1);
setProgress({scrubber: progress, thumb: progress});
playback.seek(progress * playback.duration);
},
// down
(e: MouseEvent) => {
playback.seeking = true;
const rect = scrubberBar.current.getBoundingClientRect(),
progress = clamp(0, (e.clientX - rect.left) / rect.width, 1);
setProgress({scrubber: progress, thumb: progress});
playback.seek(progress * playback.duration);
},
// up
() => (playback.seeking = false),
);
return {
onMouseDown: (e: React.MouseEvent) => listener(e.nativeEvent),
};
}, [playback]);
// events to attach on the wrapper
const wrapEvents = useMemo(() => {
const props = {} as React.HTMLAttributes<HTMLDivElement> &
React.RefAttributes<HTMLDivElement>;
if (anyHover) {
Object.assign(props, {
// show thumb preview on hover
onMouseOver: () => setShowThumb(true),
onMouseMove: (e: React.MouseEvent<HTMLDivElement>) => {
const rect = scrubberBar.current.getBoundingClientRect(),
progress = clamp(0, (e.clientX - rect.left) / rect.width, 1);
setProgress((prev) => ({scrubber: prev.scrubber, thumb: progress}));
},
onMouseOut: () => setShowThumb(false),
});
}
const listener = onDrag(
// move
(e, {x}) => {
const rect = scrubberBar.current.getBoundingClientRect(),
progress = clamp(0, (x - rect.left) / rect.width, 1);
setProgress({scrubber: progress, thumb: progress});
},
// start
(e) => {
e.preventDefault();
e.stopPropagation();
playback.seeking = true;
setShowThumb(true);
},
// end
(e: TouchEvent, {x}: {x: number}) => {
e.preventDefault();
const rect = scrubberBar.current.getBoundingClientRect(),
progress = clamp(0, (x - rect.left) / rect.width, 1);
setShowThumb(false);
playback.seeking = false;
playback.seek(progress * playback.duration);
},
);
props.ref = captureRef((ref: HTMLDivElement) => {
ref.addEventListener("touchstart", listener, {passive: false});
});
return props;
}, [playback]);
// events to be attached to the scrubber
const scrubberEvents = useMemo(() => {
// if (anyHover) return {};
const listener = onDrag(
// move
(e, {x}) => {
const rect = scrubberBar.current.getBoundingClientRect(),
progress = clamp(0, (x - rect.left) / rect.width, 1);
setProgress({scrubber: progress, thumb: progress});
},
// start
(e) => {
e.preventDefault();
e.stopPropagation();
playback.seeking = true;
setShowThumb(true);
},
// end
(e, {x}) => {
e.preventDefault();
const rect = scrubberBar.current.getBoundingClientRect(),
progress = clamp(0, (x - rect.left) / rect.width, 1);
setShowThumb(false);
playback.seeking = false;
playback.seek(progress * playback.duration);
},
);
return {
ref: captureRef((ref: SVGSVGElement) => {
ref.addEventListener("touchstart", listener, {passive: false});
}),
};
}, [playback]);
const highlights = (props.thumbs && props.thumbs.highlights) || [];
const activeHighlight = highlights.find((_) =>
between(
_.time / playback.duration,
progress.thumb,
_.time / playback.duration + 0.01,
),
);
const thumbTitle = activeHighlight ? activeHighlight.title : null;
return (
<div className="lv-controls-scrub" ref={scrubberBar} {...divEvents}>
{props.thumbs && (
<ThumbnailBox
{...props.thumbs}
progress={progress.thumb}
show={showThumb}
title={thumbTitle}
/>
)}
<div className="lv-controls-scrub-wrap" {...wrapEvents}>
<svg
className="lv-controls-scrub-progress"
preserveAspectRatio="none"
viewBox="0 0 100 10"
>
<rect
className="lv-progress-elapsed"
x="0"
y="0"
height="10"
width={progress.scrubber * 100}
/>
<rect
className="lv-progress-remaining"
x={progress.scrubber * 100}
y="0"
height="10"
width={(1 - progress.scrubber) * 100}
/>
{/*ranges.map(([start, end]) => (
<rect
key={`${start}-${end}`} className="controls-progress-buffered"
x={start / playback.duration * 100} y="0" height="10" width={(end - start) / playback.duration * 100}/>
))*/}
{highlights.map(({time}) => (
<rect
key={time}
className={["lv-thumb-highlight"]
.concat(time <= playback.currentTime ? "past" : [])
.join(" ")}
x={(time / playback.duration) * 100}
y="0"
width="1"
height="10"
/>
))}
</svg>
<svg
className="lv-scrubber"
style={{left: `calc(${progress.scrubber * 100}% - 6px)`}}
viewBox="0 0 100 100"
{...scrubberEvents}
>
<circle cx="50" cy="50" r="50" stroke="none" />
</svg>
</div>
</div>
);
}
================================================
FILE: packages/main/src/controls/Settings.tsx
================================================
import {clamp} from "@liqvid/utils/misc";
import * as React from "react";
import {useEffect, useMemo, useRef, useState} from "react";
import {usePlayer} from "../hooks";
import {onClick, useForceUpdate} from "@liqvid/utils/react";
export const PLAYBACK_RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
enum Dialogs {
None,
Main,
Speed,
Captions,
}
/** Settings menu */
export function Settings() {
const player = usePlayer(),
{keymap, playback} = player;
const [dialog, setDialog] = useState<Dialogs>(Dialogs.None);
const [currentRate, setRate] = useState(playback.playbackRate);
const forceUpdate = useForceUpdate();
useEffect(() => {
const ratechange = () => setRate(playback.playbackRate);
const canvasClick = () => setDialog(Dialogs.None);
const slowDown = () =>
(playback.playbackRate = get(
PLAYBACK_RATES,
PLAYBACK_RATES.indexOf(playback.playbackRate) - 1,
));
const speedUp = () =>
(playback.playbackRate = get(
PLAYBACK_RATES,
PLAYBACK_RATES.indexOf(playback.playbackRate) + 1,
));
// subscribe
playback.on("ratechange", ratechange);
player.hub.on("canvasClick", canvasClick);
// keyboard shortcuts
keymap.bind("Shift+<", slowDown);
keymap.bind("Shift+>", speedUp);
return () => {
playback.off("ratechange", ratechange);
pl
gitextract_isymmvu1/ ├── .gitignore ├── LICENSE ├── README.md ├── biome.json ├── build.mjs ├── package.json ├── packages/ │ ├── captioning/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── transcription.ts │ │ │ └── webvtt.ts │ │ └── tsconfig.json │ ├── cli/ │ │ ├── liqvid-cli.mjs │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.mts │ │ │ └── tasks/ │ │ │ ├── audio.mts │ │ │ ├── build.mts │ │ │ ├── config.mts │ │ │ ├── index.mts │ │ │ ├── load-sync.cts │ │ │ ├── render.mts │ │ │ ├── serve.mts │ │ │ └── thumbs.mts │ │ └── tsconfig.json │ ├── diff/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── apply.ts │ │ │ ├── builders.ts │ │ │ ├── compute.ts │ │ │ ├── index.ts │ │ │ ├── merge.ts │ │ │ ├── runes.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tests/ │ │ │ └── suite.test.ts │ │ └── tsconfig.json │ ├── duration/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── gsap/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── host/ │ │ ├── README.md │ │ ├── lv-host.js │ │ └── package.json │ ├── hydration/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── HydrateElement.tsx │ │ │ ├── HydrateOnClient.tsx │ │ │ ├── HydrateVariants.tsx │ │ │ ├── SneakyScript.tsx │ │ │ ├── golf.ts │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ └── tsconfig.json │ ├── katex/ │ │ ├── README.md │ │ ├── package.json │ │ ├── rollup.config.js │ │ ├── src/ │ │ │ ├── RenderGroup.ts │ │ │ ├── fancy.tsx │ │ │ ├── index.tsx │ │ │ ├── loading.ts │ │ │ └── plain.tsx │ │ └── tsconfig.json │ ├── keymap/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── mixedCaseVals.ts │ │ │ └── react.ts │ │ ├── tests/ │ │ │ └── keymap.test.ts │ │ └── tsconfig.json │ ├── magic/ │ │ ├── README.md │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── default-assets.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── tests/ │ │ │ └── magic.test.ts │ │ └── tsconfig.json │ ├── main/ │ │ ├── CHANGELOG.md │ │ ├── DEVELOPMENT.md │ │ ├── README.md │ │ ├── e2e/ │ │ │ ├── app/ │ │ │ │ ├── package.json │ │ │ │ ├── src/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── static/ │ │ │ │ │ ├── index.html │ │ │ │ │ └── style.css │ │ │ │ ├── tsconfig.json │ │ │ │ └── webpack.config.js │ │ │ └── tests/ │ │ │ └── Media.spec.tsx │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── rollup.config.js │ │ ├── src/ │ │ │ ├── Audio.tsx │ │ │ ├── CaptionsDisplay.tsx │ │ │ ├── Controls.tsx │ │ │ ├── IdMap.tsx │ │ │ ├── Media.ts │ │ │ ├── Player.tsx │ │ │ ├── Video.tsx │ │ │ ├── controls/ │ │ │ │ ├── Captions.tsx │ │ │ │ ├── FullScreen.tsx │ │ │ │ ├── PlayPause.tsx │ │ │ │ ├── ScrubberBar.tsx │ │ │ │ ├── Settings.tsx │ │ │ │ ├── ThumbnailBox.tsx │ │ │ │ ├── TimeDisplay.tsx │ │ │ │ └── Volume.tsx │ │ │ ├── fake-fullscreen.ts │ │ │ ├── hooks.ts │ │ │ ├── i18n.ts │ │ │ ├── index.ts │ │ │ ├── playback.ts │ │ │ ├── polyfills.ts │ │ │ ├── script.ts │ │ │ ├── utils/ │ │ │ │ ├── authoring.ts │ │ │ │ ├── dom.ts │ │ │ │ ├── interactivity.ts │ │ │ │ ├── media.ts │ │ │ │ ├── mobile.ts │ │ │ │ └── rsc.ts │ │ │ └── utils.ts │ │ ├── styl/ │ │ │ ├── controls/ │ │ │ │ ├── captions.styl │ │ │ │ ├── scrubber.styl │ │ │ │ ├── settings.styl │ │ │ │ ├── thumbs.styl │ │ │ │ ├── time.styl │ │ │ │ └── volume.styl │ │ │ ├── liqvid.styl │ │ │ └── mobile.styl │ │ ├── tests/ │ │ │ ├── DocumentTimeline.mock │ │ │ ├── IdMap.test.tsx │ │ │ ├── Player.test.tsx │ │ │ ├── controls/ │ │ │ │ ├── PlayPause.test.tsx │ │ │ │ ├── ScrubberBar.test.tsx │ │ │ │ ├── TimeDisplay.test.tsx │ │ │ │ ├── Volume.test.tsx │ │ │ │ └── __snapshots__/ │ │ │ │ ├── PlayPause.test.tsx.snap │ │ │ │ └── Volume.test.tsx.snap │ │ │ ├── hooks.test.tsx │ │ │ ├── matchMedia.mock │ │ │ └── script.test.ts │ │ └── tsconfig.json │ ├── mathjax/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── RenderGroup.ts │ │ │ ├── fancy.tsx │ │ │ ├── index.ts │ │ │ ├── loading.ts │ │ │ └── plain.tsx │ │ ├── test/ │ │ │ └── index.js │ │ └── tsconfig.json │ ├── playback/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── animation.ts │ │ │ ├── core.ts │ │ │ ├── index.ts │ │ │ └── react.ts │ │ ├── tests/ │ │ │ └── core.test.ts │ │ └── tsconfig.json │ ├── player/ │ │ └── package.json │ ├── polyfills/ │ │ ├── package.json │ │ └── src/ │ │ ├── polyfills.ts │ │ └── waapi.js │ ├── prompt/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── Cue.tsx │ │ │ ├── Prompt.tsx │ │ │ └── index.ts │ │ ├── style.css │ │ ├── style.styl │ │ └── tsconfig.json │ ├── react/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── three.tsx │ │ └── tsconfig.json │ ├── react-three/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.tsx │ │ └── tsconfig.json │ ├── recording/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── Control.tsx │ │ │ ├── RecordingManager.ts │ │ │ ├── RecordingRow.tsx │ │ │ ├── index.ts │ │ │ ├── recorder.ts │ │ │ ├── recorders/ │ │ │ │ ├── audio-recording.tsx │ │ │ │ ├── marker-recording.tsx │ │ │ │ ├── replay-data-recorder.ts │ │ │ │ └── video-recording.tsx │ │ │ └── types.ts │ │ ├── styl/ │ │ │ └── style.styl │ │ └── tsconfig.json │ ├── renderer/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.mts │ │ │ ├── tasks/ │ │ │ │ ├── convert.mts │ │ │ │ ├── join.mts │ │ │ │ ├── solidify.mts │ │ │ │ └── thumbs.mts │ │ │ ├── types.ts │ │ │ └── utils/ │ │ │ ├── binaries.mts │ │ │ ├── capture.mts │ │ │ ├── concurrency.mts │ │ │ ├── connect.mts │ │ │ ├── pool.mts │ │ │ └── stitch.mts │ │ └── tsconfig.json │ ├── server/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── ssr/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── react.ts │ │ └── tsconfig.json │ ├── utils/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── animation.ts │ │ │ ├── interaction.ts │ │ │ ├── interactivity.ts │ │ │ ├── json.ts │ │ │ ├── misc.ts │ │ │ ├── react.ts │ │ │ ├── replay-data.ts │ │ │ ├── ssr.ts │ │ │ ├── svg.ts │ │ │ ├── time.ts │ │ │ └── types.ts │ │ ├── tests/ │ │ │ ├── animation.test.ts │ │ │ ├── json.test.ts │ │ │ ├── misc.test.ts │ │ │ ├── react.test.tsx │ │ │ └── time.test.ts │ │ └── tsconfig.json │ └── xyjax/ │ ├── README.md │ ├── package.json │ ├── rollup.config.js │ ├── src/ │ │ └── index.ts │ └── tsconfig.json ├── pnpm-workspace.yaml └── tsconfig.json
SYMBOL INDEX (429 symbols across 89 files)
FILE: build.mjs
constant DIST (line 6) | const DIST = path.join(process.cwd(), "dist");
constant NODE_MODULES (line 7) | const NODE_MODULES = path.join(process.cwd(), "node_modules");
function build (line 11) | async function build() {
function walkDir (line 35) | async function walkDir(dirname, callback) {
function fixImports (line 58) | async function fixImports(filename, type = "esm") {
function findExtension (line 86) | function findExtension(pathname, relative) {
function findPackageJson (line 106) | function findPackageJson(name) {
function getPackageName (line 122) | function getPackageName(name) {
function renameExtension (line 133) | async function renameExtension(filename, extn = "mjs") {
FILE: packages/captioning/src/transcription.ts
type Transcript (line 10) | type Transcript = [string, number, number][][];
function transcribe (line 15) | async function transcribe(args: {
FILE: packages/captioning/src/webvtt.ts
function toWebVTT (line 8) | function toWebVTT(transcript: Transcript) {
function formatTime (line 28) | function formatTime(time: number): string {
function formatTimeMs (line 38) | function formatTimeMs(time: number): string {
FILE: packages/diff/src/apply.ts
function applyDiff (line 11) | function applyDiff<T>(a: T, b: ObjectDiff<T>): T {
function applyArrayDiff (line 58) | function applyArrayDiff<T>(arr: T[], diff: ArrayDiff<T>): T[] {
FILE: packages/diff/src/builders.ts
function creationDiff (line 16) | function creationDiff<K extends string, V>(key: string, value: V) {
function deletionDiff (line 25) | function deletionDiff<K extends string>(key: K) {
function arrayDiff (line 37) | function arrayDiff<K extends string, T, D extends ArrayDiff<T>>(
function objectDiff (line 49) | function objectDiff<K extends string, T, D extends ObjectDiff<T>>(
function changeDiff (line 61) | function changeDiff<K extends string, V>(key: string, value: V) {
function changeItemDiff (line 72) | function changeItemDiff<T>(offset: number, value: T): ChangeItemDiff<T> {
function arrayItemDiff (line 81) | function arrayItemDiff<T>(
function objectItemDiff (line 93) | function objectItemDiff<T, D extends ObjectDiff<T>>(
FILE: packages/diff/src/compute.ts
function diffArrays (line 15) | function diffArrays<T>(a: T[], b: T[]): ArrayDiff<T> {
function diffObjects (line 66) | function diffObjects<T>(a: T, b: T): ObjectDiff<T> {
FILE: packages/diff/src/merge.ts
function mergeArrayDiffs (line 23) | function mergeArrayDiffs<T>(
function mergeDiffs (line 163) | function mergeDiffs<T>(
FILE: packages/diff/src/types.ts
type RuneName (line 4) | type RuneName = keyof typeof runes;
type Rune (line 5) | type Rune = (typeof runes)[RuneName];
type RunedKey (line 6) | type RunedKey<
type ChangeItemDiff (line 12) | type ChangeItemDiff<T> = [offset: number, value: T];
type ObjectItemDiff (line 13) | type ObjectItemDiff<T> = [
type ArrayItemDiff (line 17) | type ArrayItemDiff<T> = [offset: RunedKey<"array">, diff: ArrayDiff<T>];
type ItemDiff (line 22) | type ItemDiff<T> =
type ArrayDiff (line 30) | type ArrayDiff<T> = [
type DeletePlaceholder (line 37) | type DeletePlaceholder = typeof deletePlaceholder;
type ObjectDiff (line 42) | type ObjectDiff<T> = {
FILE: packages/diff/src/utils.ts
function objectKeys (line 15) | function objectKeys<T extends object>(obj: T): (keyof T)[] {
function cmp (line 20) | function cmp(a: unknown, b: unknown): boolean {
function matchItemDiff (line 49) | function matchItemDiff<T, R>(
function isRune (line 72) | function isRune<R extends Rune>(
function matchRunes (line 80) | function matchRunes<T, R>(
function consume (line 100) | function consume<T>(
function getOffset (line 138) | function getOffset(offset: ItemDiff<unknown>[0]): number {
function addToOffset (line 150) | function addToOffset<O extends ItemDiff<unknown>[0]>(
function invertDiff (line 168) | function invertDiff<T>(state: T, diff: ObjectDiff<T>): ObjectDiff<T> {
FILE: packages/duration/src/index.ts
constant SECONDS (line 1) | const SECONDS = 1000,
constant MINUTES (line 1) | const SECONDS = 1000,
constant HOURS (line 1) | const SECONDS = 1000,
constant DAYS (line 1) | const SECONDS = 1000,
constant WEEKS (line 1) | const SECONDS = 1000,
type DurationLike (line 11) | type DurationLike = Duration | DurationOptions;
type DurationOptions (line 17) | interface DurationOptions {
class Duration (line 29) | class Duration {
method constructor (line 32) | constructor({
method from (line 52) | static from(val: DurationLike): Duration {
method withSetter (line 66) | static withSetter(
method inDays (line 77) | inDays(): number {
method inHours (line 81) | inHours(): number {
method inMilliseconds (line 85) | inMilliseconds(): number {
method inMinutes (line 89) | inMinutes(): number {
method inSeconds (line 93) | inSeconds(): number {
method inWeeks (line 97) | inWeeks(): number {
method between (line 103) | between(lower: DurationLike, upper: DurationLike): boolean {
method equals (line 107) | equals(other: DurationLike): boolean {
method greaterThan (line 112) | greaterThan(other: DurationLike): boolean {
method greaterThanOrEqual (line 116) | greaterThanOrEqual(other: DurationLike): boolean {
method lessThan (line 120) | lessThan(other: DurationLike): boolean {
method lessThanOrEqual (line 125) | lessThanOrEqual(other: DurationLike): boolean {
method dividedBy (line 131) | dividedBy(other: DurationLike): number {
method minus (line 136) | minus(other: DurationLike): Duration {
method plus (line 143) | plus(other: DurationLike): Duration {
method times (line 150) | times(factor: number): Duration {
FILE: packages/gsap/src/index.ts
type Playback (line 7) | interface Playback {
function useTimeline (line 15) | function useTimeline() {
function syncTimeline (line 26) | function syncTimeline(playback: Playback) {
FILE: packages/hydration/src/HydrateElement.tsx
function HydrateElement (line 8) | function HydrateElement<Config extends readonly LocalValueConfig[]>({
FILE: packages/hydration/src/HydrateOnClient.tsx
function HydrateOnClient (line 7) | function HydrateOnClient<Config extends readonly LocalValueConfig[]>({
FILE: packages/hydration/src/HydrateVariants.tsx
type BooleanVariantConfig (line 17) | interface BooleanVariantConfig extends BooleanValueConfig {
type NumericVariantConfig (line 25) | interface NumericVariantConfig extends NumericValueConfig {
type StringVariantConfig (line 30) | interface StringVariantConfig extends StringValueConfig {
type VariantConfig (line 35) | type VariantConfig =
function HydrateVariants (line 43) | function HydrateVariants(props: VariantConfig) {
FILE: packages/hydration/src/SneakyScript.tsx
type Joinable (line 3) | type Joinable = false | string | Joinable[];
function SneakyScript (line 9) | function SneakyScript({ children }: { children: Joinable }) {
function combine (line 17) | function combine(content: Joinable): string {
FILE: packages/hydration/src/types.ts
type BooleanVariant (line 2) | interface BooleanVariant {
type ComparisonVariant (line 7) | interface ComparisonVariant<T> {
type NumericVariant (line 16) | type NumericVariant = ComparisonVariant<number>;
type StringVariant (line 18) | interface StringVariant extends ComparisonVariant<string> {
type VariantsMap (line 23) | interface VariantsMap {
type ClientValueSource (line 29) | type ClientValueSource =
type BaseValueConfig (line 36) | interface BaseValueConfig {
type BooleanValueConfig (line 45) | interface BooleanValueConfig extends BaseValueConfig {
type NumericValueConfig (line 50) | interface NumericValueConfig extends BaseValueConfig {
type StringValueConfig (line 55) | interface StringValueConfig<T extends string = string>
type LocalValueConfig (line 62) | type LocalValueConfig =
type ArgType (line 67) | type ArgType<C extends LocalValueConfig> = C["type"] extends "boolean"
FILE: packages/hydration/src/utils.ts
function matches (line 22) | function matches<T extends string | number>(
function comparisonCondition (line 48) | function comparisonCondition<T>(o: ComparisonVariant<T>) {
function stringCondition (line 70) | function stringCondition(o: StringVariant) {
FILE: packages/katex/src/RenderGroup.ts
type Handle (line 15) | interface Handle {
type Props (line 20) | interface Props {
function shouldInspect (line 87) | function shouldInspect(
function leastCommonAncestor (line 102) | function leastCommonAncestor(elements: HTMLElement[]): HTMLElement {
FILE: packages/katex/src/fancy.tsx
type Props (line 6) | interface Props extends React.ComponentProps<typeof KTXPlain> {
constant KTX (line 21) | const KTX = forwardRef<Handle, Props>(function KTX(props, ref) {
FILE: packages/katex/src/loading.ts
function parseMacros (line 45) | function parseMacros(file: string) {
FILE: packages/katex/src/plain.tsx
type Handle (line 8) | interface Handle {
type Props (line 16) | interface Props extends React.HTMLAttributes<HTMLSpanElement> {
constant KTX (line 25) | const KTX = forwardRef<Handle, Props>(function KTX(props, ref) {
FILE: packages/keymap/src/index.ts
type Callback (line 3) | type Callback = (e: KeyboardEvent) => void;
type Bindings (line 5) | interface Bindings {
class Keymap (line 28) | class Keymap {
method constructor (line 31) | constructor() {
method identify (line 36) | static identify(e: KeyboardEvent) {
method normalize (line 57) | static normalize(seq: string) {
method bind (line 92) | bind(seq: string, cb: Callback) {
method unbind (line 111) | unbind(seq: string, cb: Callback) {
method getKeys (line 133) | getKeys() {
method getHandlers (line 138) | getHandlers(seq: string) {
method handle (line 144) | handle(e: KeyboardEvent) {
function cmp (line 168) | function cmp<T>(a: T, b: T) {
FILE: packages/keymap/src/react.ts
type GlobalThis (line 8) | type GlobalThis = {
function useKeymap (line 23) | function useKeymap() {
function useKeyboardShortcut (line 28) | function useKeyboardShortcut(
FILE: packages/magic/src/index.ts
function transform (line 7) | function transform(
function tag (line 111) | function tag<K extends keyof HTMLElementTagNameMap>(
FILE: packages/magic/src/types.ts
type ScriptData (line 1) | type ScriptData =
type StyleData (line 32) | type StyleData =
FILE: packages/main/e2e/app/src/index.tsx
function Lesson (line 11) | function Lesson() {
FILE: packages/main/src/Audio.tsx
class Audio (line 7) | class Audio extends Media {
method componentDidMount (line 11) | componentDidMount() {
method render (line 37) | render() {
FILE: packages/main/src/CaptionsDisplay.tsx
function Captions (line 6) | function Captions() {
FILE: packages/main/src/Controls.tsx
type Props (line 9) | interface Props {
constant TIMEOUT (line 15) | const TIMEOUT = 3000;
function Controls (line 17) | function Controls(props: Props) {
FILE: packages/main/src/IdMap.tsx
type Props (line 6) | interface Props {
class IdMap (line 15) | class IdMap extends React.PureComponent<Props> {
method constructor (line 21) | constructor(props: Props) {
method render (line 28) | render() {
method renderContent (line 42) | renderContent([foundIds, map]: [Set<string>, unknown]) {
FILE: packages/main/src/Media.ts
type Props (line 9) | interface Props extends React.HTMLAttributes<HTMLMediaElement> {
class Media (line 15) | class Media extends React.PureComponent<
method constructor (line 34) | constructor(props: Props, context: Player) {
method componentDidMount (line 55) | componentDidMount() {
method componentWillUnmount (line 107) | componentWillUnmount() {
method end (line 123) | get end(): number {
method pause (line 127) | pause(): void {
method play (line 135) | play(): Promise<void> {
method onPlay (line 142) | onPlay(): void {
method onRateChange (line 146) | onRateChange(): void {
method onSeek (line 150) | onSeek(t: number): void {
method onTimeUpdate (line 166) | onTimeUpdate(t: number): void {
method onVolumeChange (line 178) | onVolumeChange(): void {
method onDomPlay (line 183) | onDomPlay(): void {
method onDomPause (line 191) | onDomPause(): void {
function hasEnded (line 212) | function hasEnded(media: HTMLMediaElement, threshold = 0.5): boolean {
FILE: packages/main/src/Player.tsx
type PlayerEvents (line 24) | interface PlayerEvents {
type Props (line 30) | interface Props extends React.HTMLAttributes<HTMLDivElement> {
class Player (line 40) | class Player extends React.PureComponent<Props> {
method constructor (line 116) | constructor(props: Props) {
method componentDidMount (line 146) | componentDidMount() {
method updateTree (line 190) | private updateTree(): void {
method canvasClick (line 233) | private canvasClick(): void {
method onMouseUp (line 242) | onMouseUp(e: React.MouseEvent<HTMLDivElement>): void {
method allowScroll (line 265) | static allowScroll(e: React.TouchEvent | TouchEvent): void {
method preventCanvasClick (line 275) | static preventCanvasClick(e: MouseEvent | React.MouseEvent): void {
method suspendKeyCapture (line 281) | suspendKeyCapture(): void {
method resumeKeyCapture (line 286) | resumeKeyCapture(): void {
method ready (line 291) | ready(): void {
method reparseTree (line 299) | reparseTree(node: HTMLElement | SVGElement): void {
method registerBuffer (line 309) | registerBuffer(elt: HTMLMediaElement): void {
method unregisterBuffer (line 313) | unregisterBuffer(elt: HTMLMediaElement): void {
method updateBuffer (line 317) | updateBuffer(elt: HTMLMediaElement, buffers: [number, number][]): void {
method obstruct (line 327) | obstruct(event: "canplay" | "canplaythrough", task: Promise<unknown>):...
method render (line 335) | render() {
type DAGLeaf (line 368) | interface DAGLeaf {
function toposort (line 377) | function toposort(
function findClosest (line 437) | function findClosest(
FILE: packages/main/src/Video.tsx
class Video (line 6) | class Video extends Media {
method render (line 11) | render() {
FILE: packages/main/src/controls/Captions.tsx
function Captions (line 7) | function Captions() {
FILE: packages/main/src/controls/FullScreen.tsx
function FullScreen (line 18) | function FullScreen() {
FILE: packages/main/src/controls/PlayPause.tsx
function PlayPause (line 9) | function PlayPause() {
FILE: packages/main/src/controls/ScrubberBar.tsx
function ScrubberBar (line 13) | function ScrubberBar(props: {thumbs: ThumbData}) {
FILE: packages/main/src/controls/Settings.tsx
constant PLAYBACK_RATES (line 7) | const PLAYBACK_RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
type Dialogs (line 9) | enum Dialogs {
function Settings (line 17) | function Settings() {
function getMainAudio (line 209) | function getMainAudio(elt: HTMLDivElement): HTMLAudioElement {
function trackLabel (line 215) | function trackLabel(track?: TextTrack): string {
function captionsAndSubtitles (line 220) | function captionsAndSubtitles(audio: HTMLAudioElement): TextTrack[] {
function get (line 226) | function get<T>(arr: T[], i: number): T {
FILE: packages/main/src/controls/ThumbnailBox.tsx
type ThumbData (line 7) | interface ThumbData {
type Props (line 45) | interface Props extends Omit<ThumbData, "highlights"> {
type VideoHighlight (line 51) | interface VideoHighlight {
function ThumbnailBox (line 56) | function ThumbnailBox(props: Props) {
FILE: packages/main/src/controls/TimeDisplay.tsx
function TimeDisplay (line 7) | function TimeDisplay() {
FILE: packages/main/src/controls/Volume.tsx
function Volume (line 8) | function Volume() {
FILE: packages/main/src/hooks.ts
function usePlayer (line 11) | function usePlayer(): Player {
function useMarkerUpdate (line 16) | function useMarkerUpdate(
function useScript (line 32) | function useScript<M extends string = string>(): Script<M> {
function useTimeUpdate (line 37) | function useTimeUpdate(
FILE: packages/main/src/index.ts
method get (line 42) | get() {
type Liqvid (line 61) | interface Liqvid {
type Window (line 84) | interface Window {
FILE: packages/main/src/polyfills.ts
function onFullScreenChange (line 55) | function onFullScreenChange(callback: EventListener): void {
FILE: packages/main/src/script.ts
type Marker (line 8) | type Marker<M extends string = string> = [M, number, number];
type ScriptEvents (line 10) | interface ScriptEvents {
class Script (line 14) | class Script<
method constructor (line 29) | constructor(
method hub (line 88) | get hub(): this {
method markerName (line 93) | get markerName(): string {
method back (line 100) | back(): void {
method forward (line 105) | forward(): void {
method markerByName (line 115) | markerByName(name: string): Marker {
method markerNumberOf (line 123) | markerNumberOf(name: string): number {
method parseStart (line 131) | parseStart(start: number | string): number {
method parseEnd (line 141) | parseEnd(end: number | string): number {
method __updateMarker (line 151) | __updateMarker(t: number): void {
FILE: packages/main/src/utils/authoring.ts
function showIf (line 2) | function showIf(cond: boolean): {style?: React.CSSProperties} {
function during (line 14) | function during(prefix: string) {
function from (line 21) | function from(first: string, last?: string) {
FILE: packages/main/src/utils/dom.ts
function fragmentFromHTML (line 1) | function fragmentFromHTML(str: string): DocumentFragment {
FILE: packages/main/src/utils/interactivity.ts
type Move (line 4) | type Move = Parameters<typeof onDrag>[0];
type Down (line 5) | type Down = Parameters<typeof dragHelper>[1];
type DownArgs (line 6) | type DownArgs = Parameters<typeof onDrag>[1] extends (
type Up (line 12) | type Up = Parameters<typeof onDrag>[2];
function isReactMouseEvent (line 14) | function isReactMouseEvent<T>(
function dragHelper (line 24) | function dragHelper<T extends HTMLElement | SVGElement>(
function dragHelperReact (line 76) | function dragHelperReact<T extends HTMLElement | SVGElement>(
FILE: packages/main/src/utils/media.ts
function awaitMediaCanPlay (line 2) | function awaitMediaCanPlay(media: HTMLMediaElement): Promise<void> {
function awaitMediaCanPlayThrough (line 13) | function awaitMediaCanPlayThrough(
FILE: packages/main/tests/IdMap.test.tsx
function Component (line 9) | function Component() {
FILE: packages/main/tests/hooks.test.tsx
function Test (line 18) | function Test<T>(props: {
FILE: packages/mathjax/src/RenderGroup.ts
type Handle (line 14) | interface Handle {
type Props (line 19) | interface Props {
function shouldInspect (line 84) | function shouldInspect(
function leastCommonAncestor (line 99) | function leastCommonAncestor(elements: HTMLElement[]): HTMLElement {
FILE: packages/mathjax/src/fancy.tsx
type Props (line 6) | interface Props extends React.ComponentProps<typeof MJXPlain> {
constant MJX (line 21) | const MJX = forwardRef<Handle, Props>(function MJX(props, ref) {
FILE: packages/mathjax/src/plain.tsx
type Handle (line 14) | interface Handle {
type Props (line 22) | interface Props extends React.HTMLAttributes<HTMLSpanElement> {
constant MJX (line 43) | const MJX = forwardRef<Handle, Props>(function MJX(props, ref) {
method domElement (line 86) | get domElement() {
FILE: packages/mathjax/test/index.js
method domElement (line 29) | get domElement() {
function usePromise (line 42) | function usePromise(deps = []) {
constant MJX (line 55) | const MJX = forwardRef(function MJX(props, ref) {
FILE: packages/playback/src/animation.ts
type Animation (line 6) | interface Animation {
class Playback (line 16) | class Playback extends CorePlayback {
method constructor (line 23) | constructor(options: ConstructorParameters<typeof CorePlayback>[0]) {
method newAnimation (line 37) | newAnimation<T extends Element>(
method __createTimeline (line 96) | private __createTimeline(): void {
FILE: packages/playback/src/core.ts
type PlaybackEventMap (line 8) | interface PlaybackEventMap {
type PlaybackEvent (line 23) | type PlaybackEvent =
class Playback (line 44) | class Playback extends (EventEmitter as unknown as new () => StrictEvent...
method constructor (line 76) | constructor(options: {
method captions (line 106) | get captions(): DocumentFragment[] {
method captions (line 111) | set captions(captions: DocumentFragment[]) {
method duration (line 122) | get duration(): number {
method duration (line 127) | set duration(duration: number) {
method muted (line 136) | get muted(): boolean {
method muted (line 141) | set muted(val: boolean) {
method playbackRate (line 161) | get playbackRate(): number {
method playbackRate (line 166) | set playbackRate(val: number) {
method seeking (line 176) | get seeking(): boolean {
method seeking (line 184) | set seeking(val: boolean) {
method pause (line 197) | pause(): void {
method play (line 209) | play(): void {
method seek (line 224) | seek(t: number): void {
method volume (line 238) | get volume(): number {
method volume (line 243) | set volume(volume: number) {
method stop (line 266) | stop(): void {
method __advance (line 278) | private __advance(t: number): void {
method __initAudio (line 306) | private __initAudio(): void {
FILE: packages/playback/src/react.ts
type GlobalThis (line 7) | type GlobalThis = {
function usePlayback (line 23) | function usePlayback(): Playback {
function useTime (line 37) | function useTime<T = number>(
function usePlaybackEvent (line 78) | function usePlaybackEvent(
FILE: packages/prompt/src/Cue.tsx
type Props (line 5) | interface Props {
type State (line 14) | interface State {
class Cue (line 19) | class Cue extends React.PureComponent<Props, State> {
method constructor (line 22) | constructor(props: Props) {
method componentDidMount (line 30) | componentDidMount() {
method render (line 66) | render() {
function isText (line 98) | function isText(node: Node): node is Text {
FILE: packages/prompt/src/Prompt.tsx
function Prompt (line 14) | function Prompt(
function offsetParent (line 89) | function offsetParent(node: HTMLElement) {
FILE: packages/react-three/src/index.tsx
function Canvas (line 13) | function Canvas(
function Fixes (line 31) | function Fixes(props: {
FILE: packages/react/src/index.ts
function usePlayer (line 4) | function usePlayer() {
function useKeymap (line 8) | function useKeymap() {
function usePlayback (line 12) | function usePlayback() {
function useTime (line 19) | function useTime(
function combineRefs (line 39) | function combineRefs<T>(...args: React.Ref<T>[]) {
function useForceUpdate (line 52) | function useForceUpdate() {
FILE: packages/react/src/three.tsx
function ThreeCanvas (line 5) | function ThreeCanvas(props: React.ComponentProps<typeof Canvas>) {
function Fixes (line 18) | function Fixes(): null {
FILE: packages/recording/src/Control.tsx
type Props (line 22) | interface Props {
type Action (line 27) | interface Action {
type State (line 32) | interface State {
function RecordingControl (line 41) | function RecordingControl(props: Props) {
function fmtSeq (line 331) | function fmtSeq(str: string) {
function isMac (line 346) | function isMac() {
FILE: packages/recording/src/RecordingManager.ts
type EventTypes (line 7) | interface EventTypes {
class RecordingManager (line 19) | class RecordingManager extends (EventEmitter as unknown as new () => Str...
method constructor (line 49) | constructor() {
method beginRecording (line 73) | beginRecording(plugins: Record<string, Recorder<unknown, unknown>>): v...
method capture (line 117) | capture(key: string, value: unknown): void {
method endRecording (line 128) | async endRecording(): Promise<unknown> {
method getTime (line 182) | getTime(): number {
method pauseRecording (line 191) | pauseRecording(): void {
method resumeRecording (line 207) | resumeRecording(): void {
FILE: packages/recording/src/RecordingRow.tsx
type Props (line 6) | interface Props {
function RecordingRow (line 16) | function RecordingRow(props: Props) {
FILE: packages/recording/src/recorder.ts
type IntransigentReturn (line 3) | type IntransigentReturn = [number, number];
method beginRecording (line 17) | beginRecording(): void {}
method pauseRecording (line 20) | pauseRecording(): void {}
method resumeRecording (line 23) | resumeRecording(): void {}
method endRecording (line 26) | endRecording(): Promise<IntransigentReturn> | void {}
method finalizeRecording (line 29) | finalizeRecording(data: T[], startDelay = 0, stopDelay = 0): F {
method provide (line 35) | provide({
method getUpdate (line 47) | getUpdate(data: T[], lastDuration: number) {}
FILE: packages/recording/src/recorders/audio-recording.tsx
class AudioRecorder (line 41) | class AudioRecorder extends Recorder<Blob, Blob> {
method beginRecording (line 50) | beginRecording() {
method pauseRecording (line 77) | pauseRecording() {
method resumeRecording (line 81) | resumeRecording() {
method endRecording (line 85) | async endRecording() {
method finalizeRecording (line 90) | finalizeRecording(chunks: Blob[]) {
method requestRecording (line 94) | requestRecording(constraints: MediaStreamConstraints = {audio: true}) {
function AudioSaveComponent (line 116) | function AudioSaveComponent(props: {data: Blob}) {
FILE: packages/recording/src/recorders/marker-recording.tsx
type Marker (line 7) | type Marker = [string, number];
type MarkerFormatted (line 8) | type MarkerFormatted = [string, string];
class MarkerRecorder (line 23) | class MarkerRecorder extends Recorder<Marker, MarkerFormatted[]> {
method constructor (line 27) | constructor() {
method beginRecording (line 32) | beginRecording() {
method endRecording (line 37) | endRecording() {
method finalizeRecording (line 42) | finalizeRecording(data: Marker[], startDelay: number, stopDelay: numbe...
method onMarkerUpdate (line 49) | onMarkerUpdate(prevIndex: number) {
method captureMarker (line 55) | captureMarker(markerName: string) {
function MarkerSaveComponent (line 63) | function MarkerSaveComponent(props: {data: MarkerFormatted[]}) {
function format (line 83) | function format(data: unknown) {
FILE: packages/recording/src/recorders/replay-data-recorder.ts
class ReplayDataRecorder (line 4) | class ReplayDataRecorder<T> extends Recorder<
method constructor (line 10) | constructor() {
method beginRecording (line 15) | beginRecording(): void {
method finalizeRecording (line 19) | finalizeRecording(
method capture (line 43) | capture(time = this.manager.getTime(), data: T): void {
function compress (line 57) | function compress<T>(o: T, precision = 2): T {
FILE: packages/recording/src/recorders/video-recording.tsx
class VideoRecorder (line 13) | class VideoRecorder extends Recorder<Blob, Blob> {
method beginRecording (line 22) | beginRecording() {
method pauseRecording (line 49) | pauseRecording() {
method resumeRecording (line 53) | resumeRecording() {
method endRecording (line 57) | async endRecording() {
method finalizeRecording (line 62) | finalizeRecording(chunks: Blob[]) {
method requestRecording (line 66) | requestRecording(
function VideoSaveComponent (line 90) | function VideoSaveComponent(props: {data: Blob}) {
FILE: packages/recording/src/types.ts
type RecordingPlugin (line 3) | interface RecordingPlugin<
FILE: packages/renderer/src/types.ts
type ImageFormat (line 1) | type ImageFormat = "jpeg" | "png";
FILE: packages/server/src/index.ts
function createServer (line 22) | function createServer(config: {
function htmlMagic (line 106) | function htmlMagic(
function createLivereload (line 137) | function createLivereload(port: number, staticDir: string) {
function runWebpack (line 152) | function runWebpack(port: number) {
FILE: packages/ssr/src/react.ts
type ComponentLoader (line 5) | type ComponentLoader<T extends React.ComponentType<any>> = () => Promise<
function devComponent (line 12) | function devComponent<T extends React.ComponentType<any>>(
function prodComponent (line 21) | function prodComponent<T extends React.ComponentType<any>>(
function splitComponent (line 30) | function splitComponent<
FILE: packages/utils/src/animation.ts
type AnimateOptions (line 6) | interface AnimateOptions {
function animate (line 40) | function animate(
function replay (line 104) | function replay<K>({
FILE: packages/utils/src/interaction.ts
type EventListenerOptions (line 3) | interface EventListenerOptions {
function onDrag (line 20) | function onDrag(
function onClick (line 183) | function onClick<T extends HTMLElement | SVGElement>(
FILE: packages/utils/src/json.ts
type GetJSONMap (line 4) | interface GetJSONMap {}
function loadAllJSON (line 9) | function loadAllJSON() {
function loadJSON (line 28) | function loadJSON<K extends keyof GetJSONMap>(
function getJSON (line 56) | function getJSON<K extends keyof GetJSONMap>(key: K) {
FILE: packages/utils/src/misc.ts
function between (line 2) | function between(min: number, val: number, max: number) {
function bind (line 12) | function bind<T extends {[P in K]: Function}, K extends keyof T>(
function lerp (line 23) | function lerp(a: number, b: number, t: number) {
function clamp (line 33) | function clamp(min: number, val: number, max: number) {
function constrain (line 43) | function constrain(min: number, val: number, max: number) {
function range (line 50) | function range(a: number, b?: number): number[] {
function wait (line 58) | function wait(time: number): Promise<void> {
function waitFor (line 65) | function waitFor(callback: () => boolean, interval = 10): Promise<void> {
FILE: packages/utils/src/react.ts
function createUniqueContext (line 38) | function createUniqueContext<T>(
function combineRefs (line 59) | function combineRefs<T>(...args: React.Ref<T>[]): (o: T) => void {
function onClick (line 77) | function onClick<T extends HTMLElement | SVGElement>(
function onDrag (line 125) | function onDrag(
function recursiveMap (line 149) | function recursiveMap(
function useForceUpdate (line 173) | function useForceUpdate(): () => void {
function usePromise (line 182) | function usePromise(
FILE: packages/utils/src/replay-data.ts
type ReplayData (line 4) | type ReplayData<K> = [number, K][];
function concat (line 11) | function concat<T>(...args: [ReplayData<T>, number][]) {
function length (line 30) | function length<T>(data: ReplayData<T>) {
FILE: packages/utils/src/svg.ts
function screenToSVG (line 8) | function screenToSVG(
function screenToSVGVector (line 43) | function screenToSVGVector(
FILE: packages/utils/src/time.ts
constant SECONDS (line 2) | const SECONDS = 1000;
constant MINUTES (line 3) | const MINUTES = 60 * SECONDS;
constant HOURS (line 4) | const HOURS = 60 * MINUTES;
constant DAYS (line 5) | const DAYS = 24 * HOURS;
constant MINUS_SIGN (line 8) | const MINUS_SIGN = "\u2212";
function parseTime (line 22) | function parseTime(str: string): number {
function formatTimeDuration (line 55) | function formatTimeDuration(time: number): string {
function formatTime (line 92) | function formatTime(time: number): string {
function formatTimeMs (line 126) | function formatTimeMs(time: number): string {
FILE: packages/utils/src/types.ts
function assertDefined (line 2) | function assertDefined<T>(a: T): asserts a is Exclude<T, undefined> {}
function assertType (line 5) | function assertType<K>(a: unknown): asserts a is K {}
FILE: packages/utils/tests/json.test.ts
type GetJSONMap (line 4) | interface GetJSONMap {
FILE: packages/utils/tests/misc.test.ts
method a (line 40) | a() {
FILE: packages/utils/tests/react.test.tsx
function Component (line 21) | function Component(): null {
FILE: packages/utils/tests/time.test.ts
constant SECONDS (line 9) | const SECONDS = 1000;
constant MINUTES (line 10) | const MINUTES = 60 * SECONDS;
constant HOURS (line 11) | const HOURS = 60 * MINUTES;
constant DAYS (line 12) | const DAYS = 24 * HOURS;
constant MINUS_SIGN (line 14) | const MINUS_SIGN = "\u2212";
FILE: packages/xyjax/src/index.ts
type Coords (line 10) | interface Coords {
function useAnimateArrows (line 20) | function useAnimateArrows(
method get (line 101) | get() {
method set (line 105) | set(value) {
function extendXY (line 115) | function extendXY(): void {
constant MAP (line 204) | const MAP = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
function to_b58 (line 205) | function to_b58(B: Uint8Array, A: string) {
function from_b58 (line 226) | function from_b58(S: string, A: string) {
function xyEncodeColor (line 251) | function xyEncodeColor(color: string): string {
function xyDecodeColor (line 261) | function xyDecodeColor(color: string): string {
function tob52 (line 273) | function tob52(str: string): string {
function fromb52 (line 284) | function fromb52(str: string): string {
Condensed preview — 258 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (456K chars).
[
{
"path": ".gitignore",
"chars": 211,
"preview": "packages/**/LICENSE\n\n# Node\nnode_modules\ndist\ncoverage\ntsconfig.tsbuildinfo\n*.log\n\n# Configuration\n.env\n\n# Editors\n*.cod"
},
{
"path": "LICENSE",
"chars": 1063,
"preview": "MIT License\n\nCopyright (c) Yuri Sulyma\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of "
},
{
"path": "README.md",
"chars": 2104,
"preview": "# Liqvid\n\n[Liqvid](https://liqvidjs.org/) is a library for creating **interactive** videos in React.\n\n## Links\n\n[Documen"
},
{
"path": "biome.json",
"chars": 2410,
"preview": "{\n \"$schema\": \"https://biomejs.dev/schemas/2.3.8/schema.json\",\n \"assist\": {\n \"actions\": {\n \"source\": {\n "
},
{
"path": "build.mjs",
"chars": 3547,
"preview": "/* Horrifying fixer-upper for ESM imports */\nimport * as fs from \"fs\";\nimport {existsSync, promises as fsp, readFileSync"
},
{
"path": "package.json",
"chars": 1400,
"preview": "{\n \"name\": \"root\",\n \"private\": true,\n \"workspaces\": [\"packages/*\"],\n \"devDependencies\": {\n \"@babel/core\": \"^7.17."
},
{
"path": "packages/captioning/README.md",
"chars": 173,
"preview": "# @liqvid/captioning\n\nThis package provides audio transcription and captioning utilities for [Liqvid](https://liqvidjs.o"
},
{
"path": "packages/captioning/package.json",
"chars": 690,
"preview": "{\n \"name\": \"@liqvid/captioning\",\n \"version\": \"1.0.0\",\n \"description\": \"Audio transcription and captioning for Liqvid\""
},
{
"path": "packages/captioning/src/index.ts",
"chars": 79,
"preview": "export {transcribe} from \"./transcription\";\nexport {toWebVTT} from \"./webvtt\";\n"
},
{
"path": "packages/captioning/src/transcription.ts",
"chars": 2445,
"preview": "import fs, {promises as fsp} from \"fs\";\nimport {IamAuthenticator} from \"ibm-watson/auth\";\nimport SpeechToTextV1 from \"ib"
},
{
"path": "packages/captioning/src/webvtt.ts",
"chars": 1242,
"preview": "import {Transcript} from \"./transcription\";\n\n/**\n * Convert rich {@link Transcript} to WebVTT string\n * @param transcrip"
},
{
"path": "packages/captioning/tsconfig.json",
"chars": 161,
"preview": "{\n \"extends\": \"../../tsconfig.json\",\n \"compilerOptions\": {\n \"composite\": true,\n \"outDir\": \"./dist\",\n \"rootDir"
},
{
"path": "packages/cli/liqvid-cli.mjs",
"chars": 222,
"preview": "#! /usr/bin/env node\nimport * as pkg from \"./dist/index.mjs\";\n\npkg\n .main()\n .then(() => process.exit(0))\n .catch((er"
},
{
"path": "packages/cli/package.json",
"chars": 1099,
"preview": "{\n \"name\": \"@liqvid/cli\",\n \"version\": \"1.0.5\",\n \"description\": \"Liqvid command line utility\",\n \"main\": \"dist/index.j"
},
{
"path": "packages/cli/src/index.mts",
"chars": 1593,
"preview": "import {readFile} from \"fs/promises\";\nimport * as path from \"path\";\nimport {fileURLToPath} from \"url\";\nimport yargs from"
},
{
"path": "packages/cli/src/tasks/audio.mts",
"chars": 3062,
"preview": "import path from \"path\";\nimport type Yargs from \"yargs\";\nimport {DEFAULT_CONFIG, parseConfig} from \"./config.mjs\";\n\n/**\n"
},
{
"path": "packages/cli/src/tasks/build.mts",
"chars": 5106,
"preview": "import {\n ScriptData,\n scripts as defaultScripts,\n StyleData,\n styles as defaultStyles,\n transform,\n} from \"@liqvid"
},
{
"path": "packages/cli/src/tasks/config.mts",
"chars": 1474,
"preview": "import \"ts-node/register/transpile-only\";\nimport os from \"os\";\nimport path from \"path\";\n// @ts-expect-error TypeScript c"
},
{
"path": "packages/cli/src/tasks/index.mts",
"chars": 243,
"preview": "import {audio} from \"./audio.mjs\";\nimport {build} from \"./build.mjs\";\nimport {serve} from \"./serve.mjs\";\nimport {render}"
},
{
"path": "packages/cli/src/tasks/load-sync.cts",
"chars": 78,
"preview": "module.exports = function loadSync(path: string) {\n return require(path);\n};\n"
},
{
"path": "packages/cli/src/tasks/render.mts",
"chars": 4443,
"preview": "import {parseTime} from \"@liqvid/utils/time\";\nimport type Yargs from \"yargs\";\nimport {\n BROWSER_EXECUTABLE,\n CONCURREN"
},
{
"path": "packages/cli/src/tasks/serve.mts",
"chars": 1334,
"preview": "import path from \"path\";\nimport type Yargs from \"yargs\";\nimport {DEFAULT_CONFIG, parseConfig} from \"./config.mjs\";\n\n/**\n"
},
{
"path": "packages/cli/src/tasks/thumbs.mts",
"chars": 3216,
"preview": "import type Yargs from \"yargs\";\nimport {\n BROWSER_EXECUTABLE,\n CONCURRENCY,\n DEFAULT_CONFIG,\n parseConfig,\n} from \"."
},
{
"path": "packages/cli/tsconfig.json",
"chars": 238,
"preview": "{\n \"extends\": \"../../tsconfig.json\",\n \"compilerOptions\": {\n \"composite\": true,\n \"esModuleInterop\": true,\n \"ou"
},
{
"path": "packages/diff/CHANGELOG.md",
"chars": 84,
"preview": "## 1.1.0 (April 15, 2024)\n\nAdd generics\n\n## 1.0.0 (April 14, 2024)\n\nInitial release\n"
},
{
"path": "packages/diff/README.md",
"chars": 180,
"preview": "# @liqvid/diff\n\nThis package provides functions to diff Javascript objects and arrays. It is used internally by recordin"
},
{
"path": "packages/diff/jest.config.js",
"chars": 96,
"preview": "module.exports = {\n preset: \"ts-jest\",\n testPathIgnorePatterns: [\"dist\"],\n transform: {},\n};\n"
},
{
"path": "packages/diff/package.json",
"chars": 1194,
"preview": "{\n \"name\": \"@liqvid/diff\",\n \"version\": \"1.1.0\",\n \"description\": \"Object-diffing utility\",\n \"exports\": {\n \".\": {\n "
},
{
"path": "packages/diff/src/apply.ts",
"chars": 2374,
"preview": "import type {ArrayDiff, ObjectDiff} from \"./types\";\nimport {matchItemDiff, matchRunes, objectKeys} from \"./utils\";\n\n/**\n"
},
{
"path": "packages/diff/src/builders.ts",
"chars": 2373,
"preview": "import {deletePlaceholder, runes} from \"./runes\";\nimport type {\n ArrayDiff,\n ArrayItemDiff,\n ChangeItemDiff,\n Delete"
},
{
"path": "packages/diff/src/compute.ts",
"chars": 3260,
"preview": "import {assertType} from \"@liqvid/utils/types\";\nimport {\n arrayDiff,\n arrayItemDiff,\n changeDiff,\n creationDiff,\n d"
},
{
"path": "packages/diff/src/index.ts",
"chars": 571,
"preview": "export {applyArrayDiff, applyDiff} from \"./apply\";\nexport {\n arrayDiff,\n arrayItemDiff,\n changeDiff,\n changeItemDiff"
},
{
"path": "packages/diff/src/merge.ts",
"chars": 7946,
"preview": "import {assertDefined, assertType} from \"@liqvid/utils/types\";\nimport {applyArrayDiff, applyDiff} from \"./apply\";\nimport"
},
{
"path": "packages/diff/src/runes.ts",
"chars": 146,
"preview": "export const runes = {\n array: \"#\",\n change: \"=\",\n create: \"+\",\n delete: \"-\",\n object: \"@\",\n} as const;\n\nexport con"
},
{
"path": "packages/diff/src/types.ts",
"chars": 1296,
"preview": "import type {deletePlaceholder, runes} from \"./runes\";\n\n// runes\nexport type RuneName = keyof typeof runes;\nexport type "
},
{
"path": "packages/diff/src/utils.ts",
"chars": 4275,
"preview": "import {assertType} from \"@liqvid/utils/types\";\nimport {applyDiff} from \"./apply\";\nimport {diffObjects} from \"./compute\""
},
{
"path": "packages/diff/tests/suite.test.ts",
"chars": 3166,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport {applyDiff, diffObjects} from \"../src\";\n\ndescribe(\"diffOb"
},
{
"path": "packages/diff/tsconfig.json",
"chars": 199,
"preview": "{\n \"extends\": \"../../tsconfig.json\",\n \"compilerOptions\": {\n \"composite\": true,\n \"declarationDir\": \"./dist/types\""
},
{
"path": "packages/duration/README.md",
"chars": 121,
"preview": "# @liqvid/duration\n\nProvides an opaque `Duration` type to handle relative times, avoiding millisecond/second mismatches."
},
{
"path": "packages/duration/package.json",
"chars": 1286,
"preview": "{\n \"name\": \"@liqvid/duration\",\n \"version\": \"1.1.0\",\n \"description\": \"Class for unitless time intervals\",\n \"exports\":"
},
{
"path": "packages/duration/src/index.ts",
"chars": 3593,
"preview": "const SECONDS = 1000,\n MINUTES = 60 * SECONDS,\n HOURS = 60 * MINUTES,\n DAYS = 24 * HOURS,\n WEEKS = 7 * DAYS;\n\n/**\n *"
},
{
"path": "packages/duration/tsconfig.json",
"chars": 199,
"preview": "{\n \"compilerOptions\": {\n \"composite\": true,\n \"declarationDir\": \"./dist/types\",\n \"outDir\": \"./dist\",\n \"rootD"
},
{
"path": "packages/gsap/README.md",
"chars": 754,
"preview": "# @liqvid/gsap\n\nThis module provides [GSAP](https://greensock.com/gsap/) integration for Liqvid.\n\n## Installation\n\n $"
},
{
"path": "packages/gsap/package.json",
"chars": 755,
"preview": "{\n \"name\": \"@liqvid/gsap\",\n \"version\": \"1.0.1\",\n \"description\": \"GSAP bindings for Liqvid\",\n \"keywords\": [\"animation"
},
{
"path": "packages/gsap/src/index.ts",
"chars": 839,
"preview": "import gsap from \"gsap\";\nimport {Playback, usePlayer} from \"liqvid\";\n\nconst sym = Symbol();\n\ndeclare module \"liqvid\" {\n "
},
{
"path": "packages/gsap/tsconfig.json",
"chars": 187,
"preview": "{\n \"extends\": \"../../tsconfig.json\",\n \"compilerOptions\": {\n \"composite\": true,\n \"outDir\": \"./dist\",\n \"rootDir"
},
{
"path": "packages/host/README.md",
"chars": 182,
"preview": "# lv-host\n\nThis package provides a script which should be included in pages hosting [Liqvid](https://liqvidjs.org) video"
},
{
"path": "packages/host/lv-host.js",
"chars": 1812,
"preview": "\"use strict\";\n\n(() => {\n const setDims = () => {\n document.body.style.setProperty(\"--vh\", `${window.innerHeight}px`)"
},
{
"path": "packages/host/package.json",
"chars": 491,
"preview": "{\n \"name\": \"@liqvid/host\",\n \"version\": \"1.1.0\",\n \"description\": \"Liqvid host page script\",\n \"files\": [\"lv-host.js\"],"
},
{
"path": "packages/hydration/CHANGELOG.md",
"chars": 133,
"preview": "# 0.0.2 (Dec 18, 2025)\n\n- add support for [`sessionStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/ses"
},
{
"path": "packages/hydration/README.md",
"chars": 130,
"preview": "# @liqvid/hydration\n\nThis package provides some sneaky tricks to get around React hydration errors on statically generat"
},
{
"path": "packages/hydration/package.json",
"chars": 1549,
"preview": "{\n \"name\": \"@liqvid/hydration\",\n \"version\": \"0.0.2\",\n \"description\": \"Hydration magic for Liqvid\",\n \"exports\": {\n "
},
{
"path": "packages/hydration/src/HydrateElement.tsx",
"chars": 1312,
"preview": "import { isClient } from \"@liqvid/ssr\";\nimport { Root as Slot } from \"@radix-ui/react-slot\";\nimport { useId } from \"reac"
},
{
"path": "packages/hydration/src/HydrateOnClient.tsx",
"chars": 3340,
"preview": "import { isClient } from \"@liqvid/ssr\";\n\nimport { golf } from \"./golf\";\nimport { SneakyScript } from \"./SneakyScript\";\ni"
},
{
"path": "packages/hydration/src/HydrateVariants.tsx",
"chars": 3565,
"preview": "import { isClient } from \"@liqvid/ssr\";\nimport * as Slot from \"@radix-ui/react-slot\";\nimport { useId } from \"react\";\n\nim"
},
{
"path": "packages/hydration/src/SneakyScript.tsx",
"chars": 727,
"preview": "import { isClient } from \"@liqvid/ssr\";\n\ntype Joinable = false | string | Joinable[];\n\n/**\n * Render content as IIFE in "
},
{
"path": "packages/hydration/src/golf.ts",
"chars": 311,
"preview": "export const golf = {\n comma: (...vals: (string | false)[]) => vals.filter(Boolean).join(\",\"),\n cookies: \"c\",\n docume"
},
{
"path": "packages/hydration/src/index.ts",
"chars": 234,
"preview": "export { HydrateElement } from \"./HydrateElement\";\nexport { HydrateOnClient } from \"./HydrateOnClient\";\nexport { Hydrate"
},
{
"path": "packages/hydration/src/types.ts",
"chars": 1676,
"preview": "/* variant configurations */\nexport interface BooleanVariant {\n false: React.ReactElement;\n true: React.ReactElement;\n"
},
{
"path": "packages/hydration/src/utils.ts",
"chars": 1811,
"preview": "import { golf } from \"./golf\";\nimport type { ComparisonVariant, StringVariant } from \"./types\";\n\n// type Joinable = bool"
},
{
"path": "packages/hydration/tsconfig.json",
"chars": 199,
"preview": "{\n \"compilerOptions\": {\n \"composite\": true,\n \"declarationDir\": \"./dist/types\",\n \"outDir\": \"./dist\",\n \"rootD"
},
{
"path": "packages/katex/README.md",
"chars": 162,
"preview": "# @liqvid/katex\n\n[KaTeX](https://katex.org/) integration for [Liqvid](https://liqvidjs.org). See https://liqvidjs.org/do"
},
{
"path": "packages/katex/package.json",
"chars": 1503,
"preview": "{\n \"name\": \"@liqvid/katex\",\n \"version\": \"0.1.0\",\n \"description\": \"KaTeX integration for Liqvid\",\n \"files\": [\"dist/*\""
},
{
"path": "packages/katex/rollup.config.js",
"chars": 897,
"preview": "import dts from \"rollup-plugin-dts\";\n\nconst external = [\"@liqvid/utils/react\", \"react\", \"react/jsx-runtime.js\"];\n\nexport"
},
{
"path": "packages/katex/src/RenderGroup.ts",
"chars": 2910,
"preview": "import {recursiveMap, usePromise} from \"@liqvid/utils/react\";\nimport {usePlayer} from \"liqvid\";\nimport React, {\n cloneE"
},
{
"path": "packages/katex/src/fancy.tsx",
"chars": 1247,
"preview": "import {combineRefs} from \"@liqvid/utils/react\";\nimport {usePlayer} from \"liqvid\";\nimport {forwardRef, useEffect, useRef"
},
{
"path": "packages/katex/src/index.tsx",
"chars": 191,
"preview": "export {KTX} from \"./fancy\";\nexport {KaTeXReady} from \"./loading\";\nexport {Handle} from \"./plain\";\nexport {RenderGroup} "
},
{
"path": "packages/katex/src/loading.ts",
"chars": 1992,
"preview": "// option of loading KaTeX asynchronously\nconst KaTeXLoad = new Promise<typeof katex>((resolve) => {\n const script = do"
},
{
"path": "packages/katex/src/plain.tsx",
"chars": 1849,
"preview": "import {usePromise} from \"@liqvid/utils/react\";\nimport {forwardRef, useEffect, useImperativeHandle, useRef} from \"react\""
},
{
"path": "packages/katex/tsconfig.json",
"chars": 251,
"preview": "{\n \"extends\": \"../../tsconfig.json\",\n \"compilerOptions\": {\n \"composite\": true,\n \"declarationDir\": \"./dist/types\""
},
{
"path": "packages/keymap/CHANGELOG.md",
"chars": 234,
"preview": "## 1.2.1 (January 20, 2024)\n\n- include `\"use client\"` in `@liqvid/keymap/react`\n\n## 1.2.0 (September 13, 2023)\n\n- add `u"
},
{
"path": "packages/keymap/README.md",
"chars": 155,
"preview": "# @liqvid/keymap\n\nThis package provides key bindings for [Liqvid](https://liqvidjs.org). See https://liqvidjs.org/docs/r"
},
{
"path": "packages/keymap/jest.config.js",
"chars": 163,
"preview": "module.exports = {\n preset: \"ts-jest\",\n testEnvironment: \"jsdom\",\n testPathIgnorePatterns: [\"dist\"],\n coverageReport"
},
{
"path": "packages/keymap/package.json",
"chars": 1425,
"preview": "{\n \"name\": \"@liqvid/keymap\",\n \"version\": \"1.2.2\",\n \"description\": \"Key binding for Liqvid\",\n \"repository\": {\n \"ty"
},
{
"path": "packages/keymap/src/index.ts",
"chars": 4134,
"preview": "import {mixedCaseVals} from \"./mixedCaseVals\";\n\ntype Callback = (e: KeyboardEvent) => void;\n\ninterface Bindings {\n [key"
},
{
"path": "packages/keymap/src/mixedCaseVals.ts",
"chars": 3144,
"preview": "export const mixedCaseVals = [\n \"AltGraph\",\n \"CapsLock\",\n \"FnLock\",\n \"NumLock\",\n \"ScrollLock\",\n \"SymbolLock\",\n \"A"
},
{
"path": "packages/keymap/src/react.ts",
"chars": 1024,
"preview": "\"use client\";\n\nimport {createContext, useContext, useEffect} from \"react\";\nimport type {Keymap} from \".\";\n\nconst symbol "
},
{
"path": "packages/keymap/tests/keymap.test.ts",
"chars": 1317,
"preview": "import {Keymap} from \"../src/index\";\n\n/* Modifier keys cannot be tested in Keymap::identify and Keymap.handle\n due to "
},
{
"path": "packages/keymap/tsconfig.json",
"chars": 199,
"preview": "{\n \"extends\": \"../../tsconfig.json\",\n \"compilerOptions\": {\n \"composite\": true,\n \"declarationDir\": \"./dist/types\""
},
{
"path": "packages/magic/README.md",
"chars": 152,
"preview": "# @liqvid/magic\n\nThis package provides template macros for [Liqvid](https://liqvidjs.org). See https://liqvidjs.org/docs"
},
{
"path": "packages/magic/jest.config.js",
"chars": 163,
"preview": "module.exports = {\n preset: \"ts-jest\",\n testEnvironment: \"jsdom\",\n testPathIgnorePatterns: [\"dist\"],\n coverageReport"
},
{
"path": "packages/magic/package.json",
"chars": 690,
"preview": "{\n \"name\": \"@liqvid/magic\",\n \"version\": \"1.1.2\",\n \"description\": \"Templating functions for Liqvid\",\n \"main\": \"./dist"
},
{
"path": "packages/magic/src/default-assets.ts",
"chars": 1732,
"preview": "import type {ScriptData, StyleData} from \"./types\";\n\nexport const scripts: Record<string, ScriptData> = {\n host: \"https"
},
{
"path": "packages/magic/src/index.ts",
"chars": 3535,
"preview": "import type {ScriptData, StyleData} from \"./types\";\nexport type {ScriptData, StyleData} from \"./types\";\n\n/**\n * Template"
},
{
"path": "packages/magic/src/types.ts",
"chars": 895,
"preview": "export type ScriptData =\n | {\n /**\n * Whether script is crossorigin.\n * @see https://developer.mozilla"
},
{
"path": "packages/magic/tests/magic.test.ts",
"chars": 6469,
"preview": "import exp from \"constants\";\nimport {tag, transform, scripts, styles} from \"..\";\n\njest.spyOn(console, \"warn\").mockImplem"
},
{
"path": "packages/magic/tsconfig.json",
"chars": 183,
"preview": "{\n \"extends\": \"../../tsconfig.json\",\n \"compilerOptions\": {\n \"composite\": true,\n \"outDir\": \"./dist\",\n \"rootDir"
},
{
"path": "packages/main/CHANGELOG.md",
"chars": 4936,
"preview": "## 2.1.18 (April 27, 2025)\n\n- fix `Script` type to be readonly + allow narrowing the marker name\n\n## 2.1.17 (February 4,"
},
{
"path": "packages/main/DEVELOPMENT.md",
"chars": 238,
"preview": "## Testing\n\nIn order for media codecs to work in the e2e tests, Playwright may need your system Chromium instead of its "
},
{
"path": "packages/main/README.md",
"chars": 535,
"preview": "# liqvid\n\nThis is a library for making **interactive** videos in React.\n\nFor example, here's an interactive coding demo "
},
{
"path": "packages/main/e2e/app/package.json",
"chars": 580,
"preview": "{\n \"private\": true,\n \"description\": \"E2E tests for Liqvid\",\n \"main\": \"index.js\",\n \"scripts\": {\n \"build\": \"webpack"
},
{
"path": "packages/main/e2e/app/src/index.tsx",
"chars": 527,
"preview": "import {createRoot} from \"react-dom/client\";\n\nimport * as Liqvid from \"../../../src/index\";\nimport {Playback, Player, Vi"
},
{
"path": "packages/main/e2e/app/static/index.html",
"chars": 398,
"preview": "<!DOCTYPE html>\n<html>\n\n<head>\n <title></title>\n\n <meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\" /"
},
{
"path": "packages/main/e2e/app/static/style.css",
"chars": 25,
"preview": "video {\n width: 100%;\n}\n"
},
{
"path": "packages/main/e2e/app/tsconfig.json",
"chars": 399,
"preview": "{\n \"compilerOptions\": {\n \"allowJs\": true,\n \"alwaysStrict\": true,\n \"incremental\": true,\n \"jsx\": \"react-jsx\","
},
{
"path": "packages/main/e2e/app/webpack.config.js",
"chars": 976,
"preview": "const TerserPlugin = require(\"terser-webpack-plugin\");\nconst path = require(\"path\");\nconst env = process.env.NODE_ENV ||"
},
{
"path": "packages/main/e2e/tests/Media.spec.tsx",
"chars": 1607,
"preview": "import {ElementHandle, expect, JSHandle, test} from \"@playwright/test\";\nimport type {Playback, Player} from \"../../src/i"
},
{
"path": "packages/main/jest.config.js",
"chars": 131,
"preview": "module.exports = {\n preset: \"ts-jest\",\n testEnvironment: \"jsdom\",\n testPathIgnorePatterns: [\"dist\", \"e2e\"],\n transfo"
},
{
"path": "packages/main/package.json",
"chars": 2419,
"preview": "{\n \"name\": \"liqvid\",\n \"version\": \"2.1.19\",\n \"description\": \"Library for playing interactive videos using HTML/CSS/Jav"
},
{
"path": "packages/main/playwright.config.ts",
"chars": 592,
"preview": "import dotenv from \"dotenv\";\ndotenv.config();\n\nimport type {PlaywrightTestConfig} from \"@playwright/test\";\nconst config:"
},
{
"path": "packages/main/rollup.config.js",
"chars": 1816,
"preview": "import * as fs from \"fs\";\nimport {getBabelOutputPlugin} from \"@rollup/plugin-babel\";\nimport commonjs from \"@rollup/plugi"
},
{
"path": "packages/main/src/Audio.tsx",
"chars": 1352,
"preview": "import * as React from \"react\";\nimport {Media} from \"./Media\";\n\nimport {fragmentFromHTML} from \"./utils/dom\";\n\n/** Liqvi"
},
{
"path": "packages/main/src/CaptionsDisplay.tsx",
"chars": 658,
"preview": "import * as React from \"react\";\nimport {useEffect, useRef} from \"react\";\n\nimport {usePlayback} from \"@liqvid/playback/re"
},
{
"path": "packages/main/src/Controls.tsx",
"chars": 2125,
"preview": "import * as React from \"react\";\nimport {useCallback, useEffect, useRef, useState} from \"react\";\n\nimport {ScrubberBar, Th"
},
{
"path": "packages/main/src/IdMap.tsx",
"chars": 1757,
"preview": "import * as React from \"react\";\n\nimport {bind} from \"@liqvid/utils/misc\";\nimport {recursiveMap} from \"@liqvid/utils/reac"
},
{
"path": "packages/main/src/Media.ts",
"chars": 5959,
"preview": "import * as React from \"react\";\n\nimport {awaitMediaCanPlay, awaitMediaCanPlayThrough} from \"./utils/media\";\nimport {betw"
},
{
"path": "packages/main/src/Player.tsx",
"chars": 12822,
"preview": "import * as React from \"react\";\nimport {EventEmitter} from \"events\";\nimport type StrictEventEmitter from \"strict-event-e"
},
{
"path": "packages/main/src/Video.tsx",
"chars": 570,
"preview": "import * as React from \"react\";\n\nimport {Media} from \"./Media\";\n\n/** Liqvid equivalent of {@link HTMLVideoElement `<vide"
},
{
"path": "packages/main/src/controls/Captions.tsx",
"chars": 3475,
"preview": "import {onClick} from \"@liqvid/utils/react\";\nimport * as React from \"react\";\nimport {useCallback, useEffect, useMemo, us"
},
{
"path": "packages/main/src/controls/FullScreen.tsx",
"chars": 1895,
"preview": "import * as React from \"react\";\nimport {useEffect} from \"react\";\nimport {\n exitFullScreen,\n isFullScreen,\n onFullScre"
},
{
"path": "packages/main/src/controls/PlayPause.tsx",
"chars": 1887,
"preview": "import {useKeymap} from \"@liqvid/keymap/react\";\nimport {usePlayback} from \"@liqvid/playback/react\";\nimport {onClick, use"
},
{
"path": "packages/main/src/controls/ScrubberBar.tsx",
"chars": 8934,
"preview": "import * as React from \"react\";\nimport {useCallback, useEffect, useMemo, useRef, useState} from \"react\";\n\nimport {ThumbD"
},
{
"path": "packages/main/src/controls/Settings.tsx",
"chars": 7711,
"preview": "import {clamp} from \"@liqvid/utils/misc\";\nimport * as React from \"react\";\nimport {useEffect, useMemo, useRef, useState} "
},
{
"path": "packages/main/src/controls/ThumbnailBox.tsx",
"chars": 2606,
"preview": "import * as React from \"react\";\nimport {useEffect} from \"react\";\n\nimport {usePlayer} from \"../hooks\";\nimport {formatTime"
},
{
"path": "packages/main/src/controls/TimeDisplay.tsx",
"chars": 954,
"preview": "import {formatTime} from \"@liqvid/utils/time\";\nimport * as React from \"react\";\nimport {useEffect} from \"react\";\nimport {"
},
{
"path": "packages/main/src/controls/Volume.tsx",
"chars": 2877,
"preview": "import {onClick, useForceUpdate} from \"@liqvid/utils/react\";\nimport * as React from \"react\";\nimport {useCallback, useEff"
},
{
"path": "packages/main/src/fake-fullscreen.ts",
"chars": 1214,
"preview": "import {\n fullscreenEnabled,\n requestFullScreen as $requestFullScreen,\n exitFullScreen as $exitFullScreen,\n isFullSc"
},
{
"path": "packages/main/src/hooks.ts",
"chars": 1393,
"preview": "import {usePlayback} from \"@liqvid/playback/react\";\nimport {useContext, useEffect} from \"react\";\n\nexport {KeymapContext,"
},
{
"path": "packages/main/src/i18n.ts",
"chars": 183,
"preview": "export const strings = {\n EXIT_FULL_SCREEN: \"Exit full screen\",\n ENTER_FULL_SCREEN: \"Full screen\",\n MUTE: \"Mute\",\n U"
},
{
"path": "packages/main/src/index.ts",
"chars": 2062,
"preview": "import {Audio} from \"./Audio\";\nimport {IdMap} from \"./IdMap\";\nimport {Media} from \"./Media\";\nimport {Player} from \"./Pla"
},
{
"path": "packages/main/src/playback.ts",
"chars": 413,
"preview": "import {Playback} from \"@liqvid/playback\";\nimport {parseTime} from \"@liqvid/utils/time\";\n\n// backwards compatibility\nObj"
},
{
"path": "packages/main/src/polyfills.ts",
"chars": 1783,
"preview": "import {isClient} from \"./utils/rsc\";\nconst id = <T>(_: T) => _;\n\nexport const fullscreenEnabled: boolean = isClient\n ?"
},
{
"path": "packages/main/src/script.ts",
"chars": 4301,
"preview": "import {EventEmitter} from \"events\";\nimport {between, bind} from \"@liqvid/utils/misc\";\nimport {parseTime, timeRegexp} fr"
},
{
"path": "packages/main/src/utils/authoring.ts",
"chars": 609,
"preview": "// conditional display\nexport function showIf(cond: boolean): {style?: React.CSSProperties} {\n if (!cond)\n return {\n"
},
{
"path": "packages/main/src/utils/dom.ts",
"chars": 193,
"preview": "export function fragmentFromHTML(str: string): DocumentFragment {\n const t = document.createElement(\"template\");\n t.in"
},
{
"path": "packages/main/src/utils/interactivity.ts",
"chars": 3268,
"preview": "import {onDrag} from \"@liqvid/utils/interaction\";\nimport {captureRef} from \"@liqvid/utils/react\";\n\ntype Move = Parameter"
},
{
"path": "packages/main/src/utils/media.ts",
"chars": 842,
"preview": "/** Promisifed version of [canplay](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/canplay_event) eve"
},
{
"path": "packages/main/src/utils/mobile.ts",
"chars": 1696,
"preview": "import {anyHover} from \"@liqvid/utils/interaction\";\nimport {captureRef} from \"@liqvid/utils/react\";\n\nexport {\n anyHover"
},
{
"path": "packages/main/src/utils/rsc.ts",
"chars": 89,
"preview": "// work with Next.js\nexport const isClient = typeof globalThis.document !== \"undefined\";\n"
},
{
"path": "packages/main/src/utils.ts",
"chars": 594,
"preview": "/* various things we sometimes use */\nexport * as animation from \"@liqvid/utils/animation\";\nexport * as authoring from \""
},
{
"path": "packages/main/styl/controls/captions.styl",
"chars": 959,
"preview": ".lv-controls-captions,\n.rp-controls-captions\n position relative\n\n > svg > path\n fill #FFF\n \n &::after\n content"
},
{
"path": "packages/main/styl/controls/scrubber.styl",
"chars": 703,
"preview": "/* scrubbing */\n.lv-controls-scrub,\n.rp-controls-scrub\n -webkit-tap-highlight-color transparent\n \n.lv-controls-scrub-w"
},
{
"path": "packages/main/styl/controls/settings.styl",
"chars": 1231,
"preview": ".lv-controls-settings,\n.rp-controls-settings\n position relative\n \n > svg\n cursor pointer\n height 100%\n paddi"
},
{
"path": "packages/main/styl/controls/thumbs.styl",
"chars": 731,
"preview": "$thumb-img-height = 100px\n$thumb-img-width = 160px\n$thumb-padding = 3px\n\n.lv-controls-thumbnail,\n.rp-controls-thumbnail\n"
},
{
"path": "packages/main/styl/controls/time.styl",
"chars": 254,
"preview": ".lv-controls-time,\n.rp-controls-time\n display inline-block\n font-family sans-serif\n font-size 11px\n line-height 36px"
},
{
"path": "packages/main/styl/controls/volume.styl",
"chars": 1043,
"preview": "range-thumb()\n &::-webkit-slider-thumb\n {block}\n \n &::-moz-range-thumb\n {block}\n \n &::-ms-thumb\n {block}"
},
{
"path": "packages/main/styl/liqvid.styl",
"chars": 4031,
"preview": "$wine = #AF1866\n$blue = #1A69B5\n\nuser-select(value)\n user-select value\n -webkit-user-select value\n\n/* reset styles */\n"
},
{
"path": "packages/main/styl/mobile.styl",
"chars": 2534,
"preview": "@media (min-width: 401px) \n .lv-controls,\n .rp-controls\n --lv-controls-left 2\n --lv-controls-right 3\n\n@media "
},
{
"path": "packages/main/tests/DocumentTimeline.mock",
"chars": 125,
"preview": "Object.defineProperty(window, 'DocumentTimeline', {\n writable: true,\n value: jest.fn().mockImplementation(() => ({})),"
},
{
"path": "packages/main/tests/IdMap.test.tsx",
"chars": 952,
"preview": "import * as React from \"react\";\nimport {render} from \"@testing-library/react\";\n\nimport \"./matchMedia.mock\";\nimport \"./Do"
},
{
"path": "packages/main/tests/Player.test.tsx",
"chars": 792,
"preview": "import * as React from \"react\";\nimport {fireEvent, render} from \"@testing-library/react\";\n\nimport \"./matchMedia.mock\";\ni"
},
{
"path": "packages/main/tests/controls/PlayPause.test.tsx",
"chars": 1509,
"preview": "import * as React from \"react\";\nimport {fireEvent, render} from \"@testing-library/react\";\n\nimport \"../matchMedia.mock\";\n"
},
{
"path": "packages/main/tests/controls/ScrubberBar.test.tsx",
"chars": 1630,
"preview": "import * as React from \"react\";\nimport {fireEvent, render} from \"@testing-library/react\";\n\nimport \"../matchMedia.mock\";\n"
},
{
"path": "packages/main/tests/controls/TimeDisplay.test.tsx",
"chars": 950,
"preview": "import * as React from \"react\";\nimport {render} from \"@testing-library/react\";\n\nimport \"../matchMedia.mock\";\nimport \"../"
},
{
"path": "packages/main/tests/controls/Volume.test.tsx",
"chars": 1781,
"preview": "import * as React from \"react\";\nimport {fireEvent, render} from \"@testing-library/react\";\n\nimport \"../matchMedia.mock\";\n"
},
{
"path": "packages/main/tests/controls/__snapshots__/PlayPause.test.tsx.snap",
"chars": 599,
"preview": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Play/pause button Icon updates 1`] = `\n<svg\n viewBox=\"0 0 36 36\"\n>"
},
{
"path": "packages/main/tests/controls/__snapshots__/Volume.test.tsx.snap",
"chars": 1015,
"preview": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Volume button Setting volume updates button icon 1`] = `\n<svg\n vie"
},
{
"path": "packages/main/tests/hooks.test.tsx",
"chars": 1625,
"preview": "import * as React from \"react\";\nimport {render} from \"@testing-library/react\";\n\nimport \"./matchMedia.mock\";\nimport \"./Do"
},
{
"path": "packages/main/tests/matchMedia.mock",
"chars": 368,
"preview": "Object.defineProperty(window, 'matchMedia', {\n writable: true,\n value: jest.fn().mockImplementation(query => ({\n ma"
},
{
"path": "packages/main/tests/script.test.ts",
"chars": 2579,
"preview": "import \"./matchMedia.mock\";\nimport \"./DocumentTimeline.mock\";\n\nimport {Playback, Script} from \"..\";\n\ndescribe(\"Script\", "
},
{
"path": "packages/main/tsconfig.json",
"chars": 416,
"preview": "{\n \"compilerOptions\": {\n \"alwaysStrict\": true,\n \"declaration\": true,\n \"declarationDir\": \"./dist/types\",\n \"i"
},
{
"path": "packages/mathjax/.gitignore",
"chars": 29,
"preview": ".DS_Store\nnode_modules\n\ndist\n"
},
{
"path": "packages/mathjax/README.md",
"chars": 360,
"preview": "# @liqvid/mathjax\n\n[MathJax](https://mathjax.org/) plugin for [Liqvid](https://liqvidjs.org).\n\n## Usage\n\n```tsx\nimport {"
},
{
"path": "packages/mathjax/package.json",
"chars": 1532,
"preview": "{\n \"name\": \"@liqvid/mathjax\",\n \"version\": \"0.1.2\",\n \"description\": \"MathJax integration for Liqvid\",\n \"exports\": {\n "
},
{
"path": "packages/mathjax/src/RenderGroup.ts",
"chars": 2864,
"preview": "import {recursiveMap, usePromise} from \"@liqvid/utils/react\";\nimport {usePlayer} from \"liqvid\";\nimport {\n cloneElement,"
},
{
"path": "packages/mathjax/src/fancy.tsx",
"chars": 1504,
"preview": "import {combineRefs} from \"@liqvid/utils/react\";\nimport {usePlayer} from \"liqvid\";\nimport {forwardRef, useEffect, useRef"
},
{
"path": "packages/mathjax/src/index.ts",
"chars": 263,
"preview": "declare global {\n // alas: https://github.com/mathjax/MathJax/issues/2197#issuecomment-531566828\n const MathJax: any;\n"
},
{
"path": "packages/mathjax/src/loading.ts",
"chars": 354,
"preview": "/**\n * Ready Promise\n */\nconst packages = MathJax._.components.package.Package.packages;\n// output/svg doesn't load reli"
},
{
"path": "packages/mathjax/src/plain.tsx",
"chars": 6741,
"preview": "import {combineRefs, usePromise} from \"@liqvid/utils/react\";\nimport {\n createElement,\n forwardRef,\n useEffect,\n useI"
},
{
"path": "packages/mathjax/test/index.js",
"chars": 1578,
"preview": "import {jsx} from \"react/jsx-runtime\";\nimport {Utils, usePlayer} from \"liqvid\";\nimport {\n forwardRef,\n useRef,\n useEf"
},
{
"path": "packages/mathjax/tsconfig.json",
"chars": 251,
"preview": "{\n \"extends\": \"../../tsconfig.json\",\n \"compilerOptions\": {\n \"composite\": true,\n \"declarationDir\": \"./dist/types\""
},
{
"path": "packages/playback/CHANGELOG.md",
"chars": 283,
"preview": "## 1.2.0 (December 7, 2025)\n\n- setting volume programmatically should not unmute\n- add `usePlaybackEvent()`\n\n## 1.1.7 (F"
},
{
"path": "packages/playback/README.md",
"chars": 314,
"preview": "# @liqvid/playback\n\nThis package provides the `Playback` class, which is effectively an animation loop + event emitter p"
},
{
"path": "packages/playback/jest.config.js",
"chars": 146,
"preview": "module.exports = {\n coverageReporters: [\"json-summary\"],\n preset: \"ts-jest\",\n testEnvironment: \"jsdom\",\n testPathIgn"
},
{
"path": "packages/playback/package.json",
"chars": 1653,
"preview": "{\n \"name\": \"@liqvid/playback\",\n \"version\": \"1.2.0\",\n \"description\": \"Playback class for Liqvid\",\n \"exports\": {\n \""
},
{
"path": "packages/playback/src/animation.ts",
"chars": 4121,
"preview": "import { isClient } from \"@liqvid/utils/ssr\";\n\nimport { Playback as CorePlayback } from \"./core\";\n\ndeclare global {\n in"
},
{
"path": "packages/playback/src/core.ts",
"chars": 7449,
"preview": "/** biome-ignore-all lint/suspicious/noConfusingVoidType: event emitter types */\nimport { EventEmitter } from \"events\";\n"
},
{
"path": "packages/playback/src/index.ts",
"chars": 91,
"preview": "export { Playback } from \"./animation\";\nexport { Playback as CorePlayback } from \"./core\";\n"
},
{
"path": "packages/playback/src/react.ts",
"chars": 2593,
"preview": "import { createContext, useContext, useEffect, useRef } from \"react\";\n\nimport type { PlaybackEvent } from \"./core\";\n\nimp"
},
{
"path": "packages/playback/tests/core.test.ts",
"chars": 285,
"preview": "import { Playback } from \"../src/index\";\n\nit(\"should stop() when seeked to end\", () => {\n const playback = new Playback"
},
{
"path": "packages/playback/tsconfig.json",
"chars": 199,
"preview": "{\n \"compilerOptions\": {\n \"composite\": true,\n \"declarationDir\": \"./dist/types\",\n \"outDir\": \"./dist\",\n \"rootD"
},
{
"path": "packages/player/package.json",
"chars": 673,
"preview": "{\n \"name\": \"@liqvid/player\",\n \"version\": \"1.0.0\",\n \"description\": \"Player gui for Liqvid\",\n \"main\": \"dist/index.js\","
},
{
"path": "packages/polyfills/package.json",
"chars": 490,
"preview": "{\n \"name\": \"@liqvid/polyfills\",\n \"version\": \"0.0.1\",\n \"description\": \"Polyfills used by Liqvid\",\n \"files\": [\"dist/*\""
},
{
"path": "packages/polyfills/src/polyfills.ts",
"chars": 0,
"preview": ""
},
{
"path": "packages/polyfills/src/waapi.js",
"chars": 1951,
"preview": "(async () => {\n const POLYFILL_URL =\n \"https://cdnjs.cloudflare.com/ajax/libs/web-animations/2.3.2/web-animations-ne"
},
{
"path": "packages/prompt/README.md",
"chars": 823,
"preview": "# @liqvid/prompt\n\nThis is a [Liqvid](https://liqvidjs.org) plugin providing prompts to read from when recording.\n\n## Usa"
},
{
"path": "packages/prompt/package.json",
"chars": 1427,
"preview": "{\n \"name\": \"@liqvid/prompt\",\n \"version\": \"1.0.0\",\n \"description\": \"Liqvid plugin providing prompts to read from\",\n \""
},
{
"path": "packages/prompt/src/Cue.tsx",
"chars": 2189,
"preview": "import * as React from \"react\";\n\nconst NS = \"lv-prompt\";\n\ninterface Props {\n active?: boolean;\n\n children?: React.Reac"
},
{
"path": "packages/prompt/src/Prompt.tsx",
"chars": 3040,
"preview": "import * as React from \"react\";\nimport {useEffect, useMemo, useRef, useState} from \"react\";\n\nimport {Utils, usePlayer} f"
},
{
"path": "packages/prompt/src/index.ts",
"chars": 60,
"preview": "export {Cue} from \"./Cue\";\nexport {Prompt} from \"./Prompt\";\n"
},
{
"path": "packages/prompt/style.css",
"chars": 740,
"preview": ".lv-prompt {\n border-radius: 2px;\n color: #fff;\n position: absolute;\n width: 35em;\n}\n.lv-prompt > :first-child,\n.lv-"
},
{
"path": "packages/prompt/style.styl",
"chars": 627,
"preview": "$ns = lv-prompt\n\n.{$ns}\n border-radius 2px\n color #FFF\n position absolute\n width 35em\n \n > :first-child, > .{$ns}-"
},
{
"path": "packages/prompt/tsconfig.json",
"chars": 199,
"preview": "{\n \"extends\": \"../../tsconfig.json\",\n \"compilerOptions\": {\n \"composite\": true,\n \"declarationDir\": \"./dist/types\""
},
{
"path": "packages/react/package.json",
"chars": 935,
"preview": "{\n \"name\": \"@liqvid/react\",\n \"version\": \"0.0.1\",\n \"description\": \"React surface for Liqvid\",\n \"exports\": {\n \".\": "
},
{
"path": "packages/react/src/index.ts",
"chars": 1248,
"preview": "import {useContext, useEffect, useReducer} from \"react\";\nimport {Player} from \"liqvid\";\n\nexport function usePlayer() {\n "
},
{
"path": "packages/react/src/three.tsx",
"chars": 710,
"preview": "import {Canvas, useThree} from \"@react-three/fiber\";\nimport {ResizeObserver} from \"@juggle/resize-observer\";\nimport {Pla"
},
{
"path": "packages/react/tsconfig.json",
"chars": 187,
"preview": "{\n \"extends\": \"../../tsconfig.json\",\n \"compilerOptions\": {\n \"composite\": true,\n \"outDir\": \"./dist\",\n \"rootDir"
},
{
"path": "packages/react-three/README.md",
"chars": 244,
"preview": "# @liqvid/react-three\n\nThis provides integration of [@react-three/fiber](https://docs.pmnd.rs/react-three-fiber/getting-"
},
{
"path": "packages/react-three/package.json",
"chars": 1514,
"preview": "{\n \"name\": \"@liqvid/react-three\",\n \"version\": \"2.0.0\",\n \"description\": \"@react-three integration for Liqvid\",\n \"main"
},
{
"path": "packages/react-three/src/index.tsx",
"chars": 1228,
"preview": "import {ResizeObserver} from \"@juggle/resize-observer\";\nimport {useContextBridge} from \"@react-three/drei/core/useContex"
},
{
"path": "packages/react-three/tsconfig.json",
"chars": 161,
"preview": "{\n \"extends\": \"../../tsconfig.json\",\n \"compilerOptions\": {\n \"composite\": true,\n \"outDir\": \"./dist\",\n \"rootDir"
},
{
"path": "packages/recording/CHANGELOG.md",
"chars": 175,
"preview": "## 0.2.4 (Feb 4, 2025)\n\n- work with React server-side rendering\n\n## 0.2.0 (Jan 5, 2024)\n\n- support/require React 18\n\n## "
},
{
"path": "packages/recording/README.md",
"chars": 147,
"preview": "# @liqvid/recording.\n\nRecording functionality for [`Liqvid`](https://liqvidjs.org/). Documentation at https://liqvidjs.o"
},
{
"path": "packages/recording/package.json",
"chars": 1802,
"preview": "{\n \"name\": \"@liqvid/recording\",\n \"version\": \"0.2.5\",\n \"description\": \"Recording functionality for Liqvid\",\n \"exports"
},
{
"path": "packages/recording/src/Control.tsx",
"chars": 9117,
"preview": "\"use client\";\n\nimport {\n useCallback,\n useEffect,\n useMemo,\n useReducer,\n useRef,\n useState,\n} from \"react\";\n\nimpo"
},
{
"path": "packages/recording/src/RecordingManager.ts",
"chars": 4892,
"preview": "import {EventEmitter} from \"events\";\nimport {bind} from \"@liqvid/utils/misc\";\nimport type StrictEventEmitter from \"stric"
},
{
"path": "packages/recording/src/RecordingRow.tsx",
"chars": 1787,
"preview": "import {formatTimeMs} from \"@liqvid/utils/time\";\nimport {useCallback, useState} from \"react\";\n\nimport type {RecorderPlug"
},
{
"path": "packages/recording/src/index.ts",
"chars": 486,
"preview": "export type {RecorderPlugin} from \"./types\";\nexport {AudioRecorder, AudioRecording} from \"./recorders/audio-recording\";\n"
},
{
"path": "packages/recording/src/recorder.ts",
"chars": 1127,
"preview": "import type {RecordingManager} from \"./RecordingManager.js\";\n\nexport type IntransigentReturn = [number, number];\n\n/**\n *"
},
{
"path": "packages/recording/src/recorders/audio-recording.tsx",
"chars": 3950,
"preview": "import {isClient} from \"@liqvid/utils/ssr\";\n\nimport {type IntransigentReturn, Recorder} from \"../recorder\";\nimport type "
}
]
// ... and 58 more files (download for full content)
About this extraction
This page contains the full source code of the liqvidjs/player GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 258 files (404.6 KB), approximately 120.3k tokens, and a symbol index with 429 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.