Skip to content
Merged
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,21 @@ So hls.js runs wherever Media Source exists (Chrome, Firefox, Edge, Android, des
- **Remembers** volume, mute and speed across sources, and a position per `mediaId` (60 of them, least-recently-touched evicted). Every storage access is guarded — some browsers throw on merely touching `localStorage`.
- **Explains failures.** A blocked media load is a console-only event; the element's error code is the only in-page evidence. A CSP-refused load, a dropped connection and an undecodable codec each get their own sentence.

## Already have a player?

Three of our apps do — p0dcasters and rssamplifier each run a queue-aware dock, and media-streamer has a modal per source. Replacing those with this bar would delete working features to gain a nicer-looking one. What they still need is the delivery half: which engine plays this source.

```js
import { attachSource } from '@profullstack/player';

const attached = await attachSource(audioEl, { src: episode.enclosureUrl });
// attached.engine -> 'native' | 'hls' | 'mpegts'
// attached.unplayable -> a sentence, when nothing here can play it
attached.destroy();
```

No DOM is created, nothing is styled, and your UI is untouched. `createPlayer` uses exactly this internally, so there is one engine ladder rather than two that drift.

## Options

| Option | Meaning |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/player",
"version": "0.1.0",
"version": "0.2.0",
"description": "One web player for every source a Profullstack site serves: MP4, HLS, MPEG-2 transport streams and audio, with one control bar, on desktop, mobile, PWA and television.",
"keywords": [
"video",
Expand Down
138 changes: 138 additions & 0 deletions src/core/attach.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* The delivery layer on its own, with no control bar attached.
*
* `createPlayer` is the whole player: it builds a bar, owns the keyboard, and
* decides what a source may be asked to do. That is right for a page whose job
* is to show one recording, and wrong for an app that already has a player.
*
* Three of ours do. p0dcasters and rssamplifier each run a queue-aware dock —
* next, previous, a persisted playlist, a bar that outlives the page you are
* on — and media-streamer has a modal per source with its own retry and
* favourites. Replacing those with this package's bar would delete working
* features to gain a nicer-looking one. But every one of them still has to
* answer "how do these bytes reach the element", and every one answers it
* separately — which is how a podcast that ships an HLS enclosure plays on one
* of our sites and not another.
*
* So this is the half worth sharing with them: pick the engine, attach it, hand
* back something that tears down. No DOM is created, nothing is styled, and the
* caller's own UI is untouched.
*
* ```js
* const attached = await attachSource(audioEl, { src: episode.url });
* // ...later
* attached.destroy();
* ```
*/

import {
capabilitiesOf,
chooseEngine,
type Capabilities,
type EngineName,
type SourceKind,
} from './source';
import type { EngineFactory, EngineHandle, EngineInfo, QualityLevel } from '../engines/types';

export interface AttachOptions {
src: string;
kind?: SourceKind;
mimeType?: string;
/** Force live; HLS otherwise reads it from the playlist. */
live?: boolean;
/** True on a television, which wants a very different buffering profile. */
isTv?: boolean;
withCredentials?: boolean;
/** Appended to a codec failure, e.g. "VLC can — the button is beside Play." */
unplayableAdvice?: string;
/** Terminal: playback has stopped, and this is what to tell the reader. */
onError?: (message: string) => void;
/** Not terminal. Null clears whatever was showing. */
onNotice?: (message: string | null) => void;
/** Fires once the engine knows what the caller could not assume. */
onReady?: (info: EngineInfo) => void;
capabilities?: Capabilities;
engines?: Partial<Record<EngineName, EngineFactory>>;
}

export interface AttachedSource {
/** Drops the engine and releases the connection. */
destroy: () => void;
/** Which engine was chosen, for a caller that wants to say so. */
engine: EngineName;
kind: SourceKind;
levels: () => QualityLevel[];
setLevel?: (index: number) => void;
currentLevel?: () => number;
/**
* Set when nothing here can play this source. No engine is attached and
* `destroy` is a no-op; this string is the reason, in words for a reader.
*/
unplayable?: string;
}

export async function attachSource(
media: HTMLMediaElement,
options: AttachOptions
): Promise<AttachedSource> {
const caps = options.capabilities ?? capabilitiesOf();
const choice = chooseEngine(
{
src: options.src,
...(options.kind ? { kind: options.kind } : {}),
...(options.mimeType ? { mimeType: options.mimeType } : {}),
},
caps
);

const noop = (): void => undefined;
const context = {
media,
src: options.src,
isTv: options.isTv ?? false,
live: options.live ?? choice.kind === 'mpegts',
onError: options.onError ?? noop,
onNotice: options.onNotice ?? noop,
...(options.onReady ? { onReady: options.onReady } : {}),
};

if (choice.unplayable) {
options.onError?.(choice.unplayable);
return {
destroy: noop,
engine: choice.engine,
kind: choice.kind,
levels: () => [],
unplayable: choice.unplayable,
};
}

let handle: EngineHandle;
const override = options.engines?.[choice.engine];
if (override) {
handle = await override(context);
} else if (choice.engine === 'hls') {
const { createHlsEngine } = await import('../engines/hls');
handle = await createHlsEngine(context);
} else if (choice.engine === 'mpegts') {
const { createMpegtsEngine } = await import('../engines/mpegts');
handle = await createMpegtsEngine(context, {
withCredentials: options.withCredentials ?? false,
unplayableAdvice: options.unplayableAdvice ?? '',
});
} else {
const { createNativeEngine } = await import('../engines/native');
handle = await createNativeEngine(context);
}

return {
destroy: () => {
handle.destroy();
},
engine: choice.engine,
kind: choice.kind,
levels: handle.levels,
...(handle.setLevel ? { setLevel: handle.setLevel } : {}),
...(handle.currentLevel ? { currentLevel: handle.currentLevel } : {}),
};
}
86 changes: 41 additions & 45 deletions src/core/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ import {
type EngineName,
type SourceKind,
} from './source';
import type { EngineFactory, EngineHandle, QualityLevel } from '../engines/types';
import type { EngineFactory, QualityLevel } from '../engines/types';
import { attachSource, type AttachedSource } from './attach';

export interface PlayerOptions {
src: string;
Expand Down Expand Up @@ -359,7 +360,7 @@ export function createPlayer(root: HTMLElement, options: PlayerOptions): PlayerH
let lastSaved = 0;
let scrubbing = false;
let destroyed = false;
let engine: EngineHandle | null = null;
let engine: AttachedSource | null = null;
let levels: QualityLevel[] = [];

function on(
Expand Down Expand Up @@ -863,51 +864,46 @@ export function createPlayer(root: HTMLElement, options: PlayerOptions): PlayerH
// on demand. Nothing above depends on it having happened.
const attaching = attachEngine();
async function attachEngine(): Promise<void> {
if (choice.unplayable && choice.engine !== 'native') return;
const context = {
media,
src,
isTv,
live,
onError: (message: string) => {
root.classList.add('pux-player--failed');
showNotice(message);
},
onNotice: (message: string | null) => {
if (message === null) hideNotice();
else showNotice(message);
},
onReady: (info: { live: boolean; levels: QualityLevel[] }) => {
if (destroyed) return;
if (info.live !== live) {
live = info.live;
applyMode();
rebuildChapters();
}
levels = info.levels;
renderQuality();
},
};

// Nothing to attach when the source cannot play here: `init` has already
// shown the reason, and pointing a native element at, say, an .m3u8 it
// cannot parse would replace that reason with a generic media error.
if (choice.unplayable) return;
try {
const override = options.engines?.[choice.engine];
if (override) {
engine = await override(context);
} else if (choice.engine === 'hls') {
const { createHlsEngine } = await import('../engines/hls');
engine = await createHlsEngine(context);
} else if (choice.engine === 'mpegts') {
const { createMpegtsEngine } = await import('../engines/mpegts');
engine = await createMpegtsEngine(context, {
withCredentials: options.withCredentials ?? false,
unplayableAdvice: options.unplayableAdvice ?? '',
});
} else {
const { createNativeEngine } = await import('../engines/native');
engine = await createNativeEngine(context);
}
// Delegated rather than repeated. `attachSource` owns the engine ladder,
// and a second copy of it here would be the one that stops matching.
const attached = await attachSource(media, {
src,
...(options.kind ? { kind: options.kind } : {}),
...(options.mimeType ? { mimeType: options.mimeType } : {}),
live,
isTv,
capabilities: caps,
withCredentials: options.withCredentials ?? false,
unplayableAdvice: options.unplayableAdvice ?? '',
...(options.engines ? { engines: options.engines } : {}),
onError: (message: string) => {
root.classList.add('pux-player--failed');
showNotice(message);
},
onNotice: (message: string | null) => {
if (message === null) hideNotice();
else showNotice(message);
},
onReady: (info) => {
if (destroyed) return;
if (info.live !== live) {
live = info.live;
applyMode();
rebuildChapters();
}
levels = info.levels;
renderQuality();
},
});

engine = attached;
if (destroyed) {
engine.destroy();
attached.destroy();
engine = null;
return;
}
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
*/

export { createPlayer, type PlayerHandle, type PlayerOptions } from './core/player';
export { attachSource, type AttachOptions, type AttachedSource } from './core/attach';
export { formatTime, formatTimeParam, parseTimeParam } from './core/time';
export {
activeChapter,
Expand Down
Loading
Loading