# Scarlett Player - Development Guidelines

> Rendered at https://scarlettplayer.com/contributing/ · Source: https://github.com/Hackney-Enterprises-Inc/scarlett-player/blob/main/docs/contributing.md

Companion documents: [Architecture](https://scarlettplayer.com/architecture/index.md) (how the player is put
together) and [Writing a plugin](https://scarlettplayer.com/plugin-authoring/index.md) (writing a plugin package).

## Code Standards

### TypeScript

**Required**:
- All code must be TypeScript
- Strict mode enabled
- Prefer `unknown` over `any`. The exceptions in the tree are deliberate and
  local: the duck-typed provider calls in `ScarlettPlayer` (`getLevels`,
  `setLevel`, `getLiveInfo`, `loadSource`) and third-party globals such as the
  Cast SDK. Keep the cast at the call site and say why in a comment
- Explicit return types for public APIs
- TSDoc on every exported function, class and public method, with the reasoning
  when the behaviour is not obvious from the code

**Example**:
```typescript
/**
 * Loads a media source into the player
 * @param src - The source URL or object
 * @returns Promise that resolves when source is loaded
 */
public async loadSource(src: string | SourceObject): Promise<void> {
  // Implementation
}
```

### Naming Conventions

**Classes**: PascalCase
```typescript
class PluginManager { }
class HLSPlugin { }
```

**Interfaces/Types**: PascalCase with descriptive names
```typescript
interface PluginAPI { }
type EventHandler = (data: any) => void;
```

**Functions/Methods**: camelCase
```typescript
function loadSource() { }
private setupPlugin() { }
```

**Constants**: UPPER_SNAKE_CASE
```typescript
const MAX_RETRY_COUNT = 3;
const DEFAULT_VOLUME = 1.0;
```

**Files**: kebab-case
```
plugin-manager.ts
event-bus.ts
hls-provider.ts
```

**Packages**: kebab-case with scope, and the scope carries no `plugin-` prefix
```
@scarlett-player/core
@scarlett-player/hls
@scarlett-player/media-session
```

### Code Organization

**File Structure**:
```
package/
├── src/
│   ├── index.ts          # Public exports
│   ├── types.ts          # TypeScript types
│   ├── *.ts              # Implementation files
├── tests/
│   └── *.test.ts         # Test files
├── package.json
├── tsconfig.json         # Build config; excludes tests
├── tsconfig.typecheck.json  # Optional: adds type-contract tests to the program
├── vitest.config.ts
└── README.md
```

`package.json` must declare a `typecheck` and a `test` script.
`scripts/check-package-scripts.mjs` fails CI otherwise, because pnpm's
recursive run silently skips a package that has neither.

**Imports Order**:
1. External dependencies
2. Internal imports from core
3. Relative imports

```typescript
// External
import { createScope } from 'some-library';

// Internal core
import type { IPluginAPI, Plugin } from '@scarlett-player/core';

// Relative
import { HLSConfig } from './types';
import { loadLibrary } from './lib-loader';
```

### Error Handling

**Always use Error objects**:
```typescript
// Good
throw new Error('Failed to load source');

// Bad
throw 'Failed to load source';
```

**Custom error classes for specific errors**:
```typescript
class PluginError extends Error {
  constructor(
    public pluginName: string,
    message: string,
    public cause?: Error
  ) {
    super(`[${pluginName}] ${message}`);
    this.name = 'PluginError';
  }
}
```

**Try-catch for async operations**:
```typescript
async loadSource(src: string): Promise<void> {
  try {
    const response = await fetch(src);
    // ...
  } catch (error) {
    this.handleError(error);
    throw new PluginError('hls', 'Failed to load manifest', error);
  }
}
```

### Testing Requirements

**Unit Tests**:
- Every public method must have tests
- Test happy path and error cases
- Mock external dependencies

**Test File Naming**: `*.test.ts`

**Example**:
```typescript
import { describe, it, expect } from 'vitest';
import { EventBus, Logger, PluginManager, StateManager } from '@scarlett-player/core';

describe('PluginManager', () => {
  const build = () =>
    new PluginManager(new EventBus(), new StateManager(), new Logger(), {
      container: document.createElement('div'),
    });

  const plugin = {
    id: 'test',
    name: 'Test',
    version: '1.0.0',
    type: 'feature' as const,
    init: () => {},
    destroy: () => {},
  };

  it('registers a plugin', () => {
    const manager = build();

    manager.register(plugin);

    expect(manager.hasPlugin('test')).toBe(true);
    expect(manager.getPluginState('test')).toBe('registered');
  });

  it('rejects a duplicate plugin id', () => {
    const manager = build();

    manager.register(plugin);

    expect(() => manager.register(plugin)).toThrow('is already registered');
  });
});
```

Plugins are tested against a mock `IPluginAPI`, one typed helper per package
rather than a cast at every call site.

### Documentation

**README.md Required**:
Every package must have:
- Installation instructions
- Usage examples
- API documentation link
- License

**TSDoc for Public APIs**:
```typescript
/**
 * Create an HLS provider plugin.
 *
 * @param config - Plugin configuration
 * @returns Plugin instance to hand to the player
 *
 * @example
 * ```typescript
 * const player = await createPlayer({
 *   container: '#video',
 *   src: 'https://example.com/video.m3u8',
 *   plugins: [createHLSPlugin()],
 * });
 * ```
 */
export function createHLSPlugin(config: HLSPluginConfig = {}): Plugin {
  // ...
}
```

Every example in a docblock or a README constructs the player with
`createPlayer()`.

**CHANGELOG.md**:
Changelogs are per package (`packages/<name>/CHANGELOG.md`) and generated by
Changesets from the changeset files merged into `main`; never hand-edit them.
The root `CHANGELOG.md` is only a pointer to the per-package files. Your
changeset summary is what ends up in the changelog, so write it for a consumer.

## Plugin Development Guidelines

`docs/plugin-authoring.md` is the full guide: events, state keys, control-bar
controls, and the checklist for a new package. The shape in short:

### Plugin Structure

```typescript
import type { IPluginAPI, Plugin, PluginType } from '@scarlett-player/core';

export interface MyPluginConfig {
  option1?: string;
  option2?: number;
  [key: string]: unknown;
}

const DEFAULT_CONFIG: MyPluginConfig = { option1: 'a' };

export function createMyPlugin(config: MyPluginConfig = {}): Plugin {
  const merged = { ...DEFAULT_CONFIG, ...config };
  let api: IPluginAPI | null = null;

  return {
    id: 'my-plugin',
    name: 'My Plugin',
    version: '1.0.0',
    type: 'feature' as PluginType,

    init(pluginApi: IPluginAPI): void {
      api = pluginApi;

      // Register any state key this plugin owns, before first use
      api.defineState('myPluginActive', false);

      // Subscribe to events; on() returns an unsubscribe function
      api.onDestroy(api.on('playback:play', handlePlay));
      api.onDestroy(api.on('playback:pause', handlePause));

      // Every state change, as a StateChangeEvent
      api.onDestroy(api.subscribeToState(handleStateChange));
    },

    destroy(): void {
      api = null;
    },
  };
}
```

The lifecycle hook is `init(api)`. There is no `setup` hook, and `destroy()` is
required, not optional. Factory functions (`createMyPlugin()`) are the convention across the
workspace; a class is fine as long as it satisfies the same `Plugin` interface.
`IPluginAPI` is the type a plugin is handed; `PluginAPI` is core's concrete
implementation and plugins should not depend on it.

### Plugin Best Practices

1. **Always cleanup in destroy()**
   - Remove event listeners
   - Clear timers/intervals
   - Destroy DOM elements
   - Release resources

2. **Use arrow functions for event handlers**
   - Preserves `this` context
   - Easier to remove listeners

3. **Validate configuration**
   ```typescript
   constructor(config: PluginConfig) {
     if (config.timeout < 0) {
       throw new Error('timeout must be >= 0');
     }
     this.config = config;
   }
   ```

4. **Emit events for plugin actions**, namespaced with the plugin id and
   declared by merging into `PlayerEventMap`
   ```typescript
   declare module '@scarlett-player/core' {
     interface PlayerEventMap {
       'myplugin:actionCompleted': { data: string };
     }
   }

   api.emit('myplugin:actionCompleted', { data: 'value' });
   ```

5. **Handle errors gracefully.** The `error` payload is a structured
   `PlayerError`, so emit one or let the player's `ErrorHandler` build it
   ```typescript
   try {
     await doSomething();
   } catch (error) {
     api.emit('error', {
       code: ErrorCode.PLUGIN_SETUP_FAILED,
       message: (error as Error).message,
       fatal: false,
       timestamp: Date.now(),
     });
     // Don't throw, let the player continue
   }
   ```

## Git Workflow

### Branch Naming

- `main` - the only long-lived branch; everything merges here
- `feat/description` - New features
- `fix/description` - Bug fixes
- `refactor/description` - Refactoring
- `docs/description` - Documentation

`changeset-release/main` is created and owned by the release automation. Do not
branch from it or push to it.

### Commit Messages

Follow [Conventional Commits](https://www.conventionalcommits.org/):

```
type(scope): subject

body (optional)

footer (optional)
```

**Types**:
- `feat`: New feature
- `fix`: Bug fix
- `docs`: Documentation
- `style`: Code style (formatting)
- `refactor`: Code refactoring
- `test`: Tests
- `chore`: Build/tooling

**Examples**:
```
feat(core): add plugin priority system
fix(hls): resolve memory leak in event listeners
docs(api): update plugin API documentation
test(core): add PluginManager integration tests
```

### Pull Request Process

1. Create a branch from `main`
2. Make changes with tests
3. Update the documentation and the package README the change affects
4. Add a changeset (`pnpm changeset`) for anything that reaches a published
   package. Versions are never hand-edited: the group is fixed, so every package
   publishes at one version
5. Run `pnpm validate` (package-script guard, lint, build, package-type guard,
   typecheck, test)
6. Open a PR against `main`
7. Address review comments
8. Merge when approved

### What CI runs

`.github/workflows/ci.yml` runs on every push and PR, in this order:

1. `pnpm run lint`
2. `pnpm run build`
3. `node scripts/check-package-artifacts.mjs`: every file a package manifest
   advertises (`main`, `module`, `types`, `exports`) exists in `dist/`
4. `node scripts/check-embed-chunks.mjs`: every chunk an embed bundle imports
   was actually emitted
5. `node scripts/check-package-types.mjs`: every shipped `.d.ts` actually
   compiles for a consumer, not merely exists
6. `node scripts/check-package-scripts.mjs`: every package declares
   `typecheck` and `test`
7. `pnpm run typecheck`
8. `pnpm run test`

On a push to `main` — not on pull requests — CI then installs Chromium and
ffmpeg, generates the HLS fixture with `node scripts/hls-fixture.mjs`, and runs
`node scripts/verify-browser.mjs` against a freshly built demo bundle, which it
discards afterwards. It is gated to `main` because the harness takes minutes and
several scenarios assert against fixed waits tuned on a developer machine.

`pnpm validate` covers everything except the two post-build guards (3 and 4);
run those by hand after `pnpm build` when you touch a package manifest or the
embed build.

CI runs the test step on Node 24 with no extra flags: since the move to vitest 3
nothing about Node 24 needs working around.

### Releasing

Merging a changeset to `main` makes `release.yml` open (or update) a
`chore: release packages` PR on the `changeset-release/main` branch. Merging
that PR versions all nineteen packages together, publishes each to npm through
trusted publishing (OIDC, no token), tags `v<version>`, creates the GitHub
release, and uploads the embed bundles to the CDN through
`scripts/upload-cdn.sh`. Versions are never bumped by hand.

Never commit `demo/demo.bundle.js` or `docs/demo/demo.bundle.js`. The release
workflow rebuilds and commits both on every push to `main`, so a bundle
committed from a branch only produces binary merge conflicts with that commit.
Build it locally to preview, then leave it out of the PR. Do not gitignore it
either: the deployed `index.html` is stamped with a version and points at a file
that has to exist in the repo.

## Code Review Checklist

**Before Requesting Review**:
- [ ] Code follows style guidelines
- [ ] All tests pass
- [ ] New code has tests (80%+ coverage)
- [ ] Documentation updated
- [ ] No console.log statements
- [ ] TypeScript strict mode passes
- [ ] Build succeeds

**Reviewers Check**:
- [ ] Code is understandable
- [ ] Edge cases handled
- [ ] Error handling appropriate
- [ ] Performance considered
- [ ] Security concerns addressed
- [ ] Consistent with architecture
- [ ] Tests are meaningful

## Performance Guidelines

### Bundle Size

- Tree-shakeable exports; no side effects at module scope beyond control
  registration
- No circular dependencies
- Heavy dependencies load lazily. hls.js is fetched through a dynamic import
  inside `loadHlsJs()`, and the playlist plugin reaches the UI package with
  `void import('@scarlett-player/ui')` so it still works when that package is
  absent
- Watch the sizes Vite and tsup print during `pnpm build` rather than adding a
  budget nobody enforces

### Runtime Performance

- Use `requestAnimationFrame` for animations
- Debounce high-frequency events
- Avoid unnecessary re-renders
- Lazy load heavy dependencies

**Example**:
```typescript
// Debounce resize handler
private handleResize = debounce(() => {
  this.updateLayout();
}, 150);
```

### Memory Management

- Clean up event listeners
- Clear timers and intervals
- Remove DOM references
- Avoid memory leaks

Prefer `api.onDestroy(unsubscribe)` at the point of subscription over
remembering to undo each one in `destroy()`: `PluginManager` runs those cleanups
for you when the plugin is destroyed.

```typescript
destroy(): void {
  // Clear timers
  clearInterval(interval);

  // Remove DOM
  element?.remove();

  // Clear references
  api = null;
  element = null;
}
```

## Security Guidelines

### Input Validation

```typescript
loadSource(src: string | SourceObject): void {
  // Validate URL
  if (typeof src === 'string' && !this.isValidUrl(src)) {
    throw new Error('Invalid source URL');
  }

  // Sanitize if needed
  const sanitized = this.sanitizeUrl(src);
}
```

### XSS Prevention

- Never use `innerHTML` with user content
- Always sanitize URLs
- Use textContent instead of innerHTML

```typescript
// Bad
element.innerHTML = userInput;

// Good
element.textContent = userInput;
```

### CSP Compliance

Plugins must document required CSP directives:

```typescript
/**
 * Required CSP:
 * - script-src: For HLS.js loading
 * - media-src: For media sources
 * - connect-src: For manifest fetching
 */
```

## Accessibility Guidelines

**WCAG 2.1 Level AA Required**

### Keyboard Navigation

- All controls keyboard accessible
- Logical tab order
- Visible focus indicators
- Keyboard shortcuts documented

### ARIA Attributes

```typescript
button.setAttribute('aria-label', 'Play video');
button.setAttribute('aria-pressed', 'false');
slider.setAttribute('aria-valuemin', '0');
slider.setAttribute('aria-valuemax', '100');
slider.setAttribute('aria-valuenow', '50');
```

### Screen Readers

- Meaningful labels
- Status announcements
- Error messages

```typescript
// Announce state change
const liveRegion = document.createElement('div');
liveRegion.setAttribute('aria-live', 'polite');
liveRegion.setAttribute('aria-atomic', 'true');
liveRegion.textContent = 'Video playing';
```

## Version Guidelines

**Semantic Versioning** (SemVer), applied through Changesets in fixed mode: all
nineteen packages share one version number, so a release publishes them
together even where a package did not change.

- MAJOR: Breaking changes
- MINOR: New features (backwards compatible)
- PATCH: Bug fixes

**Pre-release versions**:
- `0.x.x` - Initial development
- `1.0.0-alpha.1` - Alpha
- `1.0.0-beta.1` - Beta
- `1.0.0-rc.1` - Release candidate
- `1.0.0` - Stable

## License Guidelines

**MIT License** for all packages

Include attribution for Vidstack:
```
Portions of this software were inspired by Vidstack Player
Copyright (c) 2023 Rahim Alwer
MIT License - https://github.com/vidstack/player
```

## Questions?

When in doubt:
1. Read `docs/architecture.md` for how the pieces fit together
2. Read `docs/plugin-authoring.md` if the change is a plugin
3. Read the existing code: it is the specification, and a claim about how the
   player behaves is not worth making until it has been checked against the
   source
4. Reference Vidstack for patterns (not implementation)
