diff --git a/README.md b/README.md index 2e33656..084bb6a 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/package.json b/package.json index d19579d..7fb9ab6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/attach.ts b/src/core/attach.ts new file mode 100644 index 0000000..633ad4c --- /dev/null +++ b/src/core/attach.ts @@ -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>; +} + +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 { + 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 } : {}), + }; +} diff --git a/src/core/player.ts b/src/core/player.ts index 13ca086..581a22e 100644 --- a/src/core/player.ts +++ b/src/core/player.ts @@ -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; @@ -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( @@ -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 { - 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; } diff --git a/src/index.ts b/src/index.ts index 0464680..d9f2f6a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/test/attach.test.ts b/test/attach.test.ts new file mode 100644 index 0000000..6169e1f --- /dev/null +++ b/test/attach.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi } from 'vitest'; +import { attachSource } from '../src/core/attach'; +import type { EngineContext, EngineHandle } from '../src/engines/types'; + +/** + * `attachSource` is the delivery layer for apps that already have a player — + * p0dcasters' dock, rssamplifier's playlist, media-streamer's modals. What it + * must guarantee is narrow and exact: pick the same engine `createPlayer` + * would, attach it to the caller's element, touch nothing else, and hand back + * something that really lets go. + */ +describe('attachSource', () => { + const CHROME = { mediaSource: true, nativeHls: false }; + const IOS = { mediaSource: false, nativeHls: true }; + const ANCIENT = { mediaSource: false, nativeHls: false }; + + function spyEngine() { + const destroy = vi.fn(); + let context: EngineContext | null = null; + const factory = async (ctx: EngineContext): Promise => { + context = ctx; + return Promise.resolve({ + destroy, + levels: () => [{ index: 0, height: 720, bitrate: 1, label: '720p' }], + setLevel: vi.fn(), + currentLevel: () => -1, + }); + }; + return { factory, destroy, seen: () => context }; + } + + it('picks the native engine for a progressive file', async () => { + const media = document.createElement('audio'); + const engine = spyEngine(); + const attached = await attachSource(media, { + src: 'https://x.test/ep.mp3', + capabilities: CHROME, + engines: { native: engine.factory }, + }); + expect(attached.engine).toBe('native'); + expect(attached.kind).toBe('audio'); + expect(engine.seen()?.media).toBe(media); + }); + + it('picks hls.js for a playlist wherever Media Source exists', async () => { + const engine = spyEngine(); + const attached = await attachSource(document.createElement('video'), { + src: 'https://x.test/live.m3u8', + capabilities: CHROME, + engines: { hls: engine.factory }, + }); + expect(attached.engine).toBe('hls'); + expect(attached.levels()).toHaveLength(1); + }); + + it('falls back to native HLS on iOS', async () => { + const engine = spyEngine(); + const attached = await attachSource(document.createElement('video'), { + src: 'https://x.test/live.m3u8', + capabilities: IOS, + engines: { native: engine.factory }, + }); + expect(attached.engine).toBe('native'); + }); + + it('creates no DOM and leaves the caller’s element alone', async () => { + const media = document.createElement('audio'); + const parent = document.createElement('div'); + parent.append(media); + await attachSource(media, { + src: 'https://x.test/ep.mp3', + capabilities: CHROME, + engines: { native: spyEngine().factory }, + }); + // No control bar, no wrapper, no classes: the host owns its own UI. + expect(parent.children).toHaveLength(1); + expect(parent.querySelector('.pux-player__bar')).toBeNull(); + expect(media.className).toBe(''); + }); + + it('really lets go', async () => { + const engine = spyEngine(); + const attached = await attachSource(document.createElement('video'), { + src: 'https://x.test/a.mp4', + capabilities: CHROME, + engines: { native: engine.factory }, + }); + attached.destroy(); + expect(engine.destroy).toHaveBeenCalledTimes(1); + }); + + it('reports an unplayable source instead of attaching one', async () => { + const onError = vi.fn(); + const engine = spyEngine(); + const attached = await attachSource(document.createElement('video'), { + src: 'https://x.test/live.m3u8', + capabilities: ANCIENT, + engines: { native: engine.factory, hls: engine.factory }, + onError, + }); + expect(attached.unplayable).toMatch(/cannot play HLS/i); + expect(onError).toHaveBeenCalledWith(expect.stringMatching(/cannot play HLS/i)); + expect(engine.seen()).toBeNull(); + // And destroy stays safe to call on something that never attached. + expect(() => { + attached.destroy(); + }).not.toThrow(); + }); + + it('marks a transport stream live without being told', async () => { + const engine = spyEngine(); + await attachSource(document.createElement('video'), { + src: 'https://x.test/ch.ts', + capabilities: CHROME, + engines: { mpegts: engine.factory }, + }); + expect(engine.seen()?.live).toBe(true); + }); + + it('passes the television profile through', async () => { + const engine = spyEngine(); + await attachSource(document.createElement('video'), { + src: 'https://x.test/ch.ts', + isTv: true, + capabilities: CHROME, + engines: { mpegts: engine.factory }, + }); + expect(engine.seen()?.isTv).toBe(true); + }); +}); diff --git a/test/player.test.ts b/test/player.test.ts index 2323721..bb0259e 100644 --- a/test/player.test.ts +++ b/test/player.test.ts @@ -192,7 +192,11 @@ describe('createPlayer', () => { // An HLS URL looks identical live or not; only the playlist knows, and it // arrives after the bar has already been drawn. it('switches to live mode when the engine reports a live playlist', async () => { - const { root } = mount({ src: 'https://example.test/s.m3u8', kind: 'hls' }); + const { root } = mount({ + src: 'https://example.test/s.m3u8', + kind: 'hls', + capabilities: { mediaSource: true, nativeHls: false }, + }); expect(root.classList.contains('pux-player--live')).toBe(false); await vi.waitFor(() => expect(lastContext).not.toBeNull()); @@ -216,7 +220,11 @@ describe('createPlayer', () => { describe('quality', () => { it('stays hidden when there is no choice to make', async () => { - const { root } = mount({ src: 'a.m3u8', kind: 'hls' }); + const { root } = mount({ + src: 'a.m3u8', + kind: 'hls', + capabilities: { mediaSource: true, nativeHls: false }, + }); await vi.waitFor(() => expect(lastContext).not.toBeNull()); lastContext?.onReady?.({ live: false,