Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ coverage
# Markdown
*.md
*.mdx

# Styles (hand-authored, compact formatting preserved)
styles
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ Then [import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modu
```javascript
import { Util, ILog } from '@ceeblue/web-utils';
```

The package root (`@ceeblue/web-utils`) is pure logic — no DOM, no CSS. DOM/canvas components live in the `ui` subpath:
```javascript
import { UIMetrics, UITimeline } from '@ceeblue/web-utils/ui';
```

It also ships the Ceeblue design-system stylesheets. Import the layers you need, in order — each consumes the ones before it:
```javascript
import '@ceeblue/web-utils/tokens.css'; // design tokens (colors, radii, fonts, themes)
import '@ceeblue/web-utils/foundation.css'; // reset + base typography + scrollbars
import '@ceeblue/web-utils/components.css'; // app shell + generic UI components
```
The stylesheets use [cascade layers](https://developer.mozilla.org/en-US/docs/Web/CSS/@layer) (`ceeblue.tokens` < `ceeblue.foundation` < `ceeblue.components`), so downstream styles override them without specificity hacks.

All design-system custom properties and classes are namespaced with a `cb-` prefix (`--cb-accent`, `.cb-btn`, …) to avoid collisions with the host app or other libraries. The DOM/canvas components read these tokens at runtime — `UITimeline`, for instance, resolves `--cb-accent`, `--cb-ok`/`--cb-warn`/`--cb-err`, `--cb-txt`, `--cb-track-N`, the fonts and the tooltip surface tokens — so they follow your theme automatically when the stylesheets are loaded, and fall back to sensible built-in defaults when they aren't.
> [!IMPORTANT]
>
> If your project uses TypeScript, it is recommended that you set target: "ES6" in your configuration to match our use of ES6 features and ensure that your build will succeed (for those requiring a backward-compatible UMD version, a local build is recommended).
Expand All @@ -36,12 +51,14 @@ import { Util, ILog } from '@ceeblue/web-utils';

1. [Clone](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) this repository
2. Got to the `web-utils` folder and run `npm install` to install the packages dependencies.
3. Run `npm run build`. The output will be five files placed in the **/dist/** folder:
3. Run `npm run build`. The output is placed in the **/dist/** folder, one set of files per entry point — `web-utils` (the pure-logic root) and `ui/web-utils-ui` (the DOM/canvas components):
- **web-utils.d.ts** Typescript definitions file
- **web-utils.js**: Bundled JavaScript library
- **web-utils.js.map**: Source map that associates the bundled library with the original source files
- **web-utils.min.js** Minified version of the library, optimized for size
- **web-utils.min.js.map** Source map that associates the minified library with the original source files
- the same five **ui/web-utils-ui.\*** files for the `@ceeblue/web-utils/ui` entry
- **css/tokens.css**, **css/foundation.css** and **css/components.css** design-system stylesheets

```
git clone https://github.com/CeeblueTV/web-utils.git
Expand Down
4 changes: 3 additions & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ export { WebSocketReliable, WebSocketReliableError } from './src/WebSocketReliab
export * as EpochTime from './src/EpochTime';
export { LogLevel, ILog, Log, Loggable, log } from './src/Log';
export { PlayerStats } from './src/stats/PlayerStats';
export * as Media from './src/Media';
// Export the Common Media Library as the CML namespace.
// Example usage: CML.Cmcd, CML.CmcdStreamingFormat, etc.
export * as CML from '@svta/common-media-library';

export { UIMetrics } from './src/ui/UIMetrics';
// UI components (UIMetrics, UITimeline) live in the `@ceeblue/web-utils/ui` subpath entry
// (see src/ui/index.ts) to keep this root entry free of DOM/CSS code.

const __lib__version__ = '?'; // will be replaced on building by project version

Expand Down
23 changes: 22 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,34 @@
"bugs": {
"url": "https://github.com/CeeblueTV/web-utils/issues"
},
"files": [
"dist"
],
"main": "dist/web-utils.js",
"module": "dist/web-utils.js",
"types": "dist/web-utils.d.ts",
"type": "module",
"exports": {
".": {
"types": "./dist/web-utils.d.ts",
"default": "./dist/web-utils.js"
},
"./ui": {
"types": "./dist/ui/web-utils-ui.d.ts",
"default": "./dist/ui/web-utils-ui.js"
},
"./tokens.css": "./dist/css/tokens.css",
"./foundation.css": "./dist/css/foundation.css",
"./components.css": "./dist/css/components.css",
"./package.json": "./package.json"
},
"sideEffects": [
"**/*.css"
],
"scripts": {
"build": "rollup -c",
"build:es5": "rollup -c --format umd",
"build:docs": "typedoc --tsconfig tsconfig.json index.ts",
"build:docs": "typedoc --tsconfig tsconfig.json index.ts src/ui/index.ts",
"test": "vitest --run",
"test:coverage": "vitest --run --coverage",
"lint": "eslint . && prettier --check .",
Expand Down
43 changes: 32 additions & 11 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,27 @@ import typescript from '@rollup/plugin-typescript';
import terser from '@rollup/plugin-terser';
import { dts } from 'rollup-plugin-dts';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import { copyFileSync, mkdirSync } from 'node:fs';

const input = 'index.ts';
const output = 'dist/web-utils';
// Copy the hand-authored stylesheets into dist/ so they publish to npm and are reachable on CDNs
// (jsDelivr, unpkg) at dist/<name>.css, next to the bundles.
const copyStyles = () => ({
name: 'copy-styles',
writeBundle() {
mkdirSync('dist/css', { recursive: true });
for (const file of ['tokens.css', 'foundation.css', 'components.css']) {
copyFileSync('styles/' + file, 'dist/css/' + file);
}
}
});

// Public entry points, each emitted as a self-contained bundle in dist/:
// - index: pure logic (no DOM, no CSS) → `@ceeblue/web-utils`
// - ui/index: DOM/canvas components → `@ceeblue/web-utils/ui`
const entries = [
{ input: 'index.ts', out: 'dist/web-utils' },
{ input: 'src/ui/index.ts', out: 'dist/ui/web-utils-ui' } // distinct basename: safe if files get flattened
];

export default args => {
let target;
Expand Down Expand Up @@ -51,16 +69,17 @@ export default args => {
throw new Error('Version is undefined or not a string.');
}

return [
// Each entry yields three sequential builds: bundle → minify the bundle → type definitions.
return entries.flatMap((entry, i) => [
{
// Transpile and bundle the code
input,
input: entry.input,
output: {
name: process.env.npm_package_name,
format, // iife, es, cjs, umd, amd, system
compact: true,
sourcemap: true,
file: output + '.js'
file: entry.out + '.js'
},
plugins: [
replace({
Expand All @@ -69,28 +88,30 @@ export default args => {
}),
eslint(),
typescript({ target, downlevelIteration }),
nodeResolve()
nodeResolve(),
// Emit the stylesheets once, alongside the first bundle.
...(i === 0 ? [copyStyles()] : [])
]
},
{
// Minify the bundled code
input: output + '.js',
input: entry.out + '.js',
output: {
compact: true,
sourcemap: true,
file: output + '.min.js'
file: entry.out + '.min.js'
},
plugins: [terser()],
context: 'window' // Useful for ES5 builds, ensures 'this' refers to 'window' in a browser context
},
{
// Generate type definitions
input,
input: entry.input,
output: {
compact: true,
file: output + '.d.ts'
file: entry.out + '.d.ts'
},
plugins: [dts()]
}
];
]);
};
82 changes: 82 additions & 0 deletions src/Media.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Copyright 2024 Ceeblue B.V.
* This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License.
* See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { Type, Codec, MAX_GOP_DURATION, typeToString, screenResolution, overScreenSize } from './Media';

describe('Media', () => {
it('exposes the media vocabulary constants', () => {
expect(MAX_GOP_DURATION).toBe(10000);
expect(Type.DATA).toBe(0);
expect(Type.AUDIO).toBe(1);
expect(Type.VIDEO).toBe(2);
expect(Codec.UNKNOWN).toBe('');
expect(Codec.H264).toBe('H264');
expect(Codec.OPUS).toBe('OPUS');
});

describe('typeToString', () => {
it('maps each known type to its name', () => {
expect(typeToString(Type.AUDIO)).toBe('audio');
expect(typeToString(Type.VIDEO)).toBe('video');
expect(typeToString(Type.DATA)).toBe('data');
});
it('falls back to "unknown" for an unmapped value', () => {
expect(typeToString(99 as Type)).toBe('unknown');
});
});

describe('overScreenSize', () => {
it('is true only when the resolution exceeds the screen on both axes', () => {
expect(overScreenSize({ width: 1920, height: 1080 }, { width: 1280, height: 720 })).toBe(true);
expect(overScreenSize({ width: 1280, height: 720 }, { width: 1920, height: 1080 })).toBe(false);
// wider but not taller → not over on both axes
expect(overScreenSize({ width: 3000, height: 500 }, { width: 1920, height: 1080 })).toBe(false);
});
it('is falsy when no screen is provided', () => {
expect(overScreenSize({ width: 1920, height: 1080 })).toBeFalsy();
});
});

describe('screenResolution', () => {
const realScreen = Object.getOwnPropertyDescriptor(window, 'screen');
const realRatio = Object.getOwnPropertyDescriptor(window, 'devicePixelRatio');
const setScreen = (value: unknown) => Object.defineProperty(window, 'screen', { configurable: true, value });
const setRatio = (value: unknown) =>
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value });

afterEach(() => {
if (realScreen) {
Object.defineProperty(window, 'screen', realScreen);
}
if (realRatio) {
Object.defineProperty(window, 'devicePixelRatio', realRatio);
}
});

it('scales landscape dimensions by the device pixel ratio', () => {
setScreen({ width: 1280, height: 720 });
setRatio(2);
expect(screenResolution()).toEqual({ width: 2560, height: 1440 });
});

it('swaps axes for a portrait screen so it reports the max fullscreen ability', () => {
setScreen({ width: 1080, height: 1920 });
setRatio(1);
expect(screenResolution()).toEqual({ width: 1920, height: 1080 });
});

it('defaults the ratio to 1 when devicePixelRatio is absent', () => {
setScreen({ width: 800, height: 600 });
setRatio(0);
expect(screenResolution()).toEqual({ width: 800, height: 600 });
});

it('returns undefined when there is no screen', () => {
setScreen(undefined);
expect(screenResolution()).toBeUndefined();
});
});
});
124 changes: 124 additions & 0 deletions src/Media.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Copyright 2024 Ceeblue B.V.
* This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License.
* See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details.
*/

/**
* Maximum GOP (group-of-pictures) duration in milliseconds — a convenient averaging window.
*/
export const MAX_GOP_DURATION = 10000;

/**
* Media type of a track or sample. Numeric so tracks can be ordered (video first).
*/
export enum Type {
DATA = 0,
AUDIO = 1,
VIDEO = 2
}

/**
* Media codec, empty string when unknown.
*/
export enum Codec {
UNKNOWN = '',
// Video
H264 = 'H264',
HEVC = 'HEVC',
VP8 = 'VP8',
// Audio
MP3 = 'MP3',
AAC = 'AAC',
OPUS = 'OPUS',
// Data
ID3 = 'ID3',
JSON = 'JSON',
SUBTITLE = 'SUBTITLE'
}

/**
* A single media sample (frame). This is the protocol-agnostic input vocabulary consumed by UI
* widgets such as `UITimeline`: any producer able to emit this shape can feed them.
*/
export type Sample = {
time: number;
duration: number;
data: Uint8Array;
compositionOffset?: number;
isKeyFrame?: boolean;
subSamples?: Array<{ clearBytes: number; encryptedBytes: number }>; // DRM field for SENC box
iv?: Uint8Array; // DRM per-sample IV (when ContentProtection.ivMode === 'sample')
};

/**
* Track selection.
*/
export type Tracks = {
/**
* Audio track, undefined = MBR, -1 = Remove the track
*/
audio?: number;
/**
* Video track, undefined = MBR, -1 = Remove the track
*/
video?: number;
/**
* Datas tracks to receive, undefined = ALL
*/
data?: Set<number>;
};

/**
* A pixel resolution.
*/
export type Resolution = {
width: number;
height: number;
};

/**
* Human-readable name of a media {@link Type}.
* @param type media type
*/
export function typeToString(type: Type) {
switch (type) {
case Type.AUDIO:
return 'audio';
case Type.VIDEO:
return 'video';
case Type.DATA:
return 'data';
default:
}
return 'unknown';
}

/**
* The display resolution in device pixels, or undefined outside a browser. In portrait the axes are
* swapped so the result always represents the maximum fullscreen ability (landscape orientation).
* @returns the screen {@link Resolution}, or undefined when there is no DOM
*/
export function screenResolution(): Resolution | undefined {
if (typeof window === 'undefined' || !window.screen) {
return;
}
const ratio = window.devicePixelRatio || 1;
let height = ratio * window.screen.height;
let width = ratio * window.screen.width;
if (height > width) {
// smartphone, switch to compute max fullscreen ability (height becomes width)
[width, height] = [height, width];
}
return { width, height };
}

/**
* Whether a resolution exceeds the displayable screen.
* @param resolution the resolution to test
* @param screen the screen resolution to compare against
* @returns true when resolution is larger than screen on both axes
*/
export function overScreenSize(resolution: Resolution, screen?: Resolution) {
return screen && resolution.height > screen.height && resolution.width > screen.width;
}
Loading
Loading