@sveltesentio/media 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +53 -4
- package/package.json +14 -12
- package/src/image.ts +160 -0
- package/src/index.ts +37 -2
- package/src/player.ts +255 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.0](https://github.com/golusoris/sveltesentio/compare/media-v0.1.0...media-v0.2.0)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **player:** headless HLS source model — `pickRendition` (separate-rendition quality switching with `maxHeight` / `preferCodec`), `buildMediaSessionMetadata`, a pure `playbackReducer` play/pause/quality state machine, and a bring-your-own-`hls.js` `createHlsAttachment` seam ([#67](https://github.com/golusoris/sveltesentio/issues/67), [#68](https://github.com/golusoris/sveltesentio/issues/68))
|
|
9
|
+
* **image:** pure responsive-image `buildSrcSet` / `buildSizes` / `buildResponsiveImage` builders with template + query-merge URL strategies ([#67](https://github.com/golusoris/sveltesentio/issues/67))
|
|
10
|
+
* sub-exports `./player` and `./image`; `hls.js` declared as an optional peer (no runtime media deps)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Notes
|
|
14
|
+
|
|
15
|
+
* The `vidstack@next` `<Player>` UI shell and the `./carousel` re-export remain follow-throughs; README and AGENTS status reconciled per [#67](https://github.com/golusoris/sveltesentio/issues/67).
|
|
16
|
+
|
|
3
17
|
## [0.1.0](https://github.com/golusoris/sveltesentio/compare/media-v0.0.1...media-v0.1.0) (2026-06-14)
|
|
4
18
|
|
|
5
19
|
|
package/README.md
CHANGED
|
@@ -1,20 +1,69 @@
|
|
|
1
1
|
# @sveltesentio/media
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> Headless media-player logic + responsive-image builders for media-server UIs
|
|
4
4
|
|
|
5
5
|
Part of the [sveltesentio](https://github.com/golusoris/sveltesentio) composable SvelteKit framework.
|
|
6
6
|
|
|
7
7
|
## Status
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
v0.2.0 — headless core landed. The pure logic for HLS rendition selection,
|
|
10
|
+
OS media-session metadata, a play/pause/quality state machine, a
|
|
11
|
+
bring-your-own-`hls.js` attachment seam, and responsive-image `srcset` / `sizes`
|
|
12
|
+
builders all ship and are unit-tested. The full `<Player>` UI shell
|
|
13
|
+
(`vidstack`) and the `./carousel` re-export are tracked as follow-throughs (see
|
|
14
|
+
[AGENTS.md](AGENTS.md)) and are **not** in this release.
|
|
10
15
|
|
|
11
|
-
##
|
|
16
|
+
## Install
|
|
12
17
|
|
|
13
18
|
```bash
|
|
14
19
|
pnpm add @sveltesentio/media
|
|
20
|
+
# optional, only if you drive adaptive HLS yourself:
|
|
21
|
+
pnpm add hls.js
|
|
15
22
|
```
|
|
16
23
|
|
|
17
|
-
|
|
24
|
+
`hls.js` is an **optional** peer — nothing in this package imports it. You
|
|
25
|
+
inject its constructor at the `createHlsAttachment` seam.
|
|
26
|
+
|
|
27
|
+
## `@sveltesentio/media/player`
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import {
|
|
31
|
+
pickRendition,
|
|
32
|
+
buildMediaSessionMetadata,
|
|
33
|
+
playbackReducer,
|
|
34
|
+
initialPlaybackState,
|
|
35
|
+
createHlsAttachment,
|
|
36
|
+
} from '@sveltesentio/media/player';
|
|
37
|
+
|
|
38
|
+
// Separate-rendition (un-muxed) quality switching: prefer HEVC, cap at 1080p.
|
|
39
|
+
const best = pickRendition(renditions, { maxHeight: 1080, preferCodec: 'hvc1' });
|
|
40
|
+
|
|
41
|
+
// OS lock-screen / media-keys metadata (pass to `new MediaMetadata(...)`).
|
|
42
|
+
const meta = buildMediaSessionMetadata({ title, artist, artwork });
|
|
43
|
+
|
|
44
|
+
// Pure play/pause/quality machine — invalid transitions are no-ops.
|
|
45
|
+
let state = playbackReducer(initialPlaybackState, { type: 'load' });
|
|
46
|
+
|
|
47
|
+
// Bring-your-own hls.js — no dynamic import, no bundled engine.
|
|
48
|
+
import Hls from 'hls.js';
|
|
49
|
+
const handle = createHlsAttachment(Hls).attach(videoEl, manifestUrl);
|
|
50
|
+
// handle.destroy();
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## `@sveltesentio/media/image`
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { buildResponsiveImage } from '@sveltesentio/media/image';
|
|
57
|
+
|
|
58
|
+
const attrs = buildResponsiveImage('/images/poster/{path}', [320, 640, 1280], {
|
|
59
|
+
template: '/images/poster/w{w}/abc.avif',
|
|
60
|
+
sizes: [{ condition: '(min-width: 768px)', size: '33vw' }],
|
|
61
|
+
});
|
|
62
|
+
// → { src, srcset, sizes } — spread onto <img>.
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
See the [monorepo README](../../README.md) and [`docs/`](../../docs/) for design
|
|
66
|
+
principles.
|
|
18
67
|
|
|
19
68
|
## License
|
|
20
69
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sveltesentio/media",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Headless media player model (HLS rendition picking, media-session, play/pause/quality machine, BYO hls.js seam) + responsive-image srcset/sizes builders",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
7
7
|
"sideEffects": false,
|
|
@@ -9,20 +9,22 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"import": "./src/index.ts",
|
|
11
11
|
"types": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"./player": {
|
|
14
|
+
"import": "./src/player.ts",
|
|
15
|
+
"types": "./src/player.ts"
|
|
16
|
+
},
|
|
17
|
+
"./image": {
|
|
18
|
+
"import": "./src/image.ts",
|
|
19
|
+
"types": "./src/image.ts"
|
|
12
20
|
}
|
|
13
21
|
},
|
|
14
22
|
"peerDependencies": {
|
|
15
|
-
"
|
|
16
|
-
"hls.js": ">=1.6.0",
|
|
17
|
-
"embla-carousel-svelte": ">=8.0.0",
|
|
18
|
-
"svelte": ">=5.0.0"
|
|
23
|
+
"hls.js": ">=1.6.0"
|
|
19
24
|
},
|
|
20
25
|
"peerDependenciesMeta": {
|
|
21
26
|
"hls.js": {
|
|
22
27
|
"optional": true
|
|
23
|
-
},
|
|
24
|
-
"embla-carousel-svelte": {
|
|
25
|
-
"optional": true
|
|
26
28
|
}
|
|
27
29
|
},
|
|
28
30
|
"devDependencies": {
|
|
@@ -32,9 +34,9 @@
|
|
|
32
34
|
"keywords": [
|
|
33
35
|
"sveltesentio",
|
|
34
36
|
"media",
|
|
35
|
-
"vidstack",
|
|
36
37
|
"hls",
|
|
37
|
-
"
|
|
38
|
+
"srcset",
|
|
39
|
+
"media-session",
|
|
38
40
|
"10-foot-ui"
|
|
39
41
|
],
|
|
40
42
|
"publishConfig": {
|
|
@@ -48,6 +50,6 @@
|
|
|
48
50
|
"build": "tsc",
|
|
49
51
|
"lint": "eslint src/",
|
|
50
52
|
"typecheck": "tsc --noEmit",
|
|
51
|
-
"test": "vitest run
|
|
53
|
+
"test": "vitest run"
|
|
52
54
|
}
|
|
53
55
|
}
|
package/src/image.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure responsive-image helpers: `srcset` / `sizes` string builders plus a
|
|
3
|
+
* width-descriptor candidate model. Framework-agnostic — no DOM, no Svelte —
|
|
4
|
+
* so the same logic drives a `<picture>`, an `<img srcset>`, or a server-side
|
|
5
|
+
* `Link: rel=preload` header.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** A `src`-templating function: maps a target pixel width to a URL. */
|
|
9
|
+
export type SrcWidthTemplate = (width: number) => string;
|
|
10
|
+
|
|
11
|
+
/** One `srcset` candidate: a URL paired with its intrinsic pixel width. */
|
|
12
|
+
export interface SrcSetCandidate {
|
|
13
|
+
readonly url: string;
|
|
14
|
+
readonly width: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SrcSetOptions {
|
|
18
|
+
/**
|
|
19
|
+
* How to derive each candidate URL from a width. Either a template function
|
|
20
|
+
* or a token-substitution string containing `{w}` (replaced with the width).
|
|
21
|
+
* When omitted, `?w=<width>` is appended to `src` (or merged into an
|
|
22
|
+
* existing query string).
|
|
23
|
+
*/
|
|
24
|
+
readonly template?: SrcWidthTemplate | string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const WIDTH_TOKEN = /\{w\}/g;
|
|
28
|
+
|
|
29
|
+
/** De-duplicate, drop non-positive/non-finite entries, and sort ascending. */
|
|
30
|
+
function normaliseWidths(widths: readonly number[]): number[] {
|
|
31
|
+
const seen = new Set<number>();
|
|
32
|
+
for (const w of widths) {
|
|
33
|
+
if (Number.isFinite(w) && w > 0) seen.add(Math.round(w));
|
|
34
|
+
}
|
|
35
|
+
return [...seen].sort((a, b) => a - b);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Append/merge a `w` query param onto a URL without a URL parser dependency. */
|
|
39
|
+
function appendWidthQuery(src: string, width: number): string {
|
|
40
|
+
const [base, hash = ''] = src.split('#', 2);
|
|
41
|
+
const safeBase = base ?? src;
|
|
42
|
+
const sep = safeBase.includes('?') ? '&' : '?';
|
|
43
|
+
const suffix = hash ? `#${hash}` : '';
|
|
44
|
+
return `${safeBase}${sep}w=${width}${suffix}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveTemplate(
|
|
48
|
+
src: string,
|
|
49
|
+
template: SrcWidthTemplate | string | undefined,
|
|
50
|
+
): SrcWidthTemplate {
|
|
51
|
+
if (typeof template === 'function') return template;
|
|
52
|
+
if (typeof template === 'string') {
|
|
53
|
+
return (width) => template.replace(WIDTH_TOKEN, String(width));
|
|
54
|
+
}
|
|
55
|
+
return (width) => appendWidthQuery(src, width);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build the structured candidate list backing a `srcset`. Widths are
|
|
60
|
+
* normalised (de-duped, positive, integer, ascending) so callers can pass a
|
|
61
|
+
* raw breakpoint list in any order.
|
|
62
|
+
*/
|
|
63
|
+
export function buildSrcSetCandidates(
|
|
64
|
+
src: string,
|
|
65
|
+
widths: readonly number[],
|
|
66
|
+
options: SrcSetOptions = {},
|
|
67
|
+
): SrcSetCandidate[] {
|
|
68
|
+
const resolved = resolveTemplate(src, options.template);
|
|
69
|
+
return normaliseWidths(widths).map((width) => ({
|
|
70
|
+
url: resolved(width),
|
|
71
|
+
width,
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build a `srcset` attribute string with `w` width descriptors, e.g.
|
|
77
|
+
* `"/img?w=320 320w, /img?w=640 640w"`. Returns `""` for an empty width list.
|
|
78
|
+
*/
|
|
79
|
+
export function buildSrcSet(
|
|
80
|
+
src: string,
|
|
81
|
+
widths: readonly number[],
|
|
82
|
+
options: SrcSetOptions = {},
|
|
83
|
+
): string {
|
|
84
|
+
return buildSrcSetCandidates(src, widths, options)
|
|
85
|
+
.map((c) => `${c.url} ${c.width}w`)
|
|
86
|
+
.join(', ');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One `sizes` media-condition / length pair. */
|
|
90
|
+
export interface SizesRule {
|
|
91
|
+
/** A media condition, e.g. `"(min-width: 768px)"`. */
|
|
92
|
+
readonly condition: string;
|
|
93
|
+
/** A CSS length, e.g. `"50vw"` or `"600px"`. */
|
|
94
|
+
readonly size: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface BuildSizesOptions {
|
|
98
|
+
/**
|
|
99
|
+
* The trailing default length applied when no condition matches. Defaults to
|
|
100
|
+
* `"100vw"` — the responsive-image-correct fallback for a fluid layout.
|
|
101
|
+
*/
|
|
102
|
+
readonly fallback?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build a `sizes` attribute string from ordered conditional rules plus a
|
|
107
|
+
* fallback length, e.g. `"(min-width: 768px) 50vw, 100vw"`. Rules are emitted
|
|
108
|
+
* in array order — the browser uses the first matching condition.
|
|
109
|
+
*/
|
|
110
|
+
export function buildSizes(
|
|
111
|
+
rules: readonly SizesRule[],
|
|
112
|
+
options: BuildSizesOptions = {},
|
|
113
|
+
): string {
|
|
114
|
+
const fallback = options.fallback ?? '100vw';
|
|
115
|
+
const conditional = rules.map((r) => `${r.condition} ${r.size}`);
|
|
116
|
+
return [...conditional, fallback].join(', ');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ResponsiveImageAttrs {
|
|
120
|
+
readonly src: string;
|
|
121
|
+
readonly srcset: string;
|
|
122
|
+
readonly sizes: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface BuildResponsiveImageOptions extends SrcSetOptions {
|
|
126
|
+
readonly sizes?: readonly SizesRule[];
|
|
127
|
+
readonly sizesFallback?: string;
|
|
128
|
+
/**
|
|
129
|
+
* Which width to use for the legacy `src` fallback attribute. Defaults to
|
|
130
|
+
* the largest provided width so non-`srcset` browsers get full quality.
|
|
131
|
+
*/
|
|
132
|
+
readonly fallbackWidth?: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Compose `{ src, srcset, sizes }` ready to spread onto an `<img>`. The `src`
|
|
137
|
+
* fallback targets `fallbackWidth` (default: largest width) so legacy browsers
|
|
138
|
+
* without `srcset` support still receive a sensibly-sized asset.
|
|
139
|
+
*/
|
|
140
|
+
export function buildResponsiveImage(
|
|
141
|
+
src: string,
|
|
142
|
+
widths: readonly number[],
|
|
143
|
+
options: BuildResponsiveImageOptions = {},
|
|
144
|
+
): ResponsiveImageAttrs {
|
|
145
|
+
const candidates = buildSrcSetCandidates(src, widths, options);
|
|
146
|
+
const fallbackWidth =
|
|
147
|
+
options.fallbackWidth ?? candidates.at(-1)?.width;
|
|
148
|
+
const resolved = resolveTemplate(src, options.template);
|
|
149
|
+
const fallbackSrc =
|
|
150
|
+
fallbackWidth === undefined ? src : resolved(fallbackWidth);
|
|
151
|
+
return {
|
|
152
|
+
src: fallbackSrc,
|
|
153
|
+
srcset: candidates.map((c) => `${c.url} ${c.width}w`).join(', '),
|
|
154
|
+
sizes: buildSizes(options.sizes ?? [], {
|
|
155
|
+
...(options.sizesFallback === undefined
|
|
156
|
+
? {}
|
|
157
|
+
: { fallback: options.sizesFallback }),
|
|
158
|
+
}),
|
|
159
|
+
};
|
|
160
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,37 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
export type {
|
|
2
|
+
HlsRendition,
|
|
3
|
+
PickRenditionOptions,
|
|
4
|
+
MediaSessionArtwork,
|
|
5
|
+
MediaSessionMetadataInit,
|
|
6
|
+
MediaSessionMetadata,
|
|
7
|
+
PlaybackStatus,
|
|
8
|
+
PlaybackEvent,
|
|
9
|
+
PlaybackState,
|
|
10
|
+
HlsLike,
|
|
11
|
+
HlsConstructorLike,
|
|
12
|
+
HlsAttachmentOptions,
|
|
13
|
+
HlsAttachment,
|
|
14
|
+
} from './player.js';
|
|
15
|
+
export {
|
|
16
|
+
pickRendition,
|
|
17
|
+
buildMediaSessionMetadata,
|
|
18
|
+
initialPlaybackState,
|
|
19
|
+
playbackReducer,
|
|
20
|
+
createHlsAttachment,
|
|
21
|
+
} from './player.js';
|
|
22
|
+
|
|
23
|
+
export type {
|
|
24
|
+
SrcWidthTemplate,
|
|
25
|
+
SrcSetCandidate,
|
|
26
|
+
SrcSetOptions,
|
|
27
|
+
SizesRule,
|
|
28
|
+
BuildSizesOptions,
|
|
29
|
+
ResponsiveImageAttrs,
|
|
30
|
+
BuildResponsiveImageOptions,
|
|
31
|
+
} from './image.js';
|
|
32
|
+
export {
|
|
33
|
+
buildSrcSet,
|
|
34
|
+
buildSrcSetCandidates,
|
|
35
|
+
buildSizes,
|
|
36
|
+
buildResponsiveImage,
|
|
37
|
+
} from './image.js';
|
package/src/player.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless player-source model: rendition selection, OS media-session
|
|
3
|
+
* metadata, a typed play/pause/quality state machine, and a bring-your-own
|
|
4
|
+
* `hls.js` attachment seam. Pure and framework-agnostic — the heavy
|
|
5
|
+
* `<Player>` UI shell (vidstack) is a follow-through and intentionally absent
|
|
6
|
+
* here so this package stays free of runtime media dependencies.
|
|
7
|
+
*
|
|
8
|
+
* @see ADR-0042 (Vidstack `@next` + `hls.js`) — issues #67 / #68.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One HLS variant. Models separate-rendition (un-muxed) streaming as used by
|
|
13
|
+
* media servers: a rendition may carry video, audio, or both, so quality and
|
|
14
|
+
* audio-track switching are independent concerns.
|
|
15
|
+
*/
|
|
16
|
+
export interface HlsRendition {
|
|
17
|
+
/** Stable identifier (e.g. the HLS level index or a server stream id). */
|
|
18
|
+
readonly id: string;
|
|
19
|
+
/** Vertical resolution in pixels; omit for audio-only renditions. */
|
|
20
|
+
readonly height?: number;
|
|
21
|
+
/** Average/peak bitrate in bits per second, if known. */
|
|
22
|
+
readonly bitrate?: number;
|
|
23
|
+
/** RFC 6381 codec string, e.g. `"hvc1.1.6.L93.B0"` or `"avc1.640028"`. */
|
|
24
|
+
readonly codec?: string;
|
|
25
|
+
/** BCP 47 language tag for audio renditions, e.g. `"en"`. */
|
|
26
|
+
readonly language?: string;
|
|
27
|
+
/** Whether this is the server-default rendition. */
|
|
28
|
+
readonly default?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PickRenditionOptions {
|
|
32
|
+
/** Cap selection to renditions at or below this height. */
|
|
33
|
+
readonly maxHeight?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Prefer renditions whose `codec` starts with this prefix (case-insensitive),
|
|
36
|
+
* e.g. `"hvc1"` / `"hev1"` for HEVC, `"avc1"` for H.264. Non-matching
|
|
37
|
+
* renditions remain eligible as a fallback.
|
|
38
|
+
*/
|
|
39
|
+
readonly preferCodec?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isAtOrBelowHeight(r: HlsRendition, maxHeight: number | undefined): boolean {
|
|
43
|
+
if (maxHeight === undefined) return true;
|
|
44
|
+
if (r.height === undefined) return true; // audio-only is never excluded by height
|
|
45
|
+
return r.height <= maxHeight;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function rank(r: HlsRendition): number {
|
|
49
|
+
if (r.height !== undefined) return r.height * 1_000_000 + (r.bitrate ?? 0);
|
|
50
|
+
return r.bitrate ?? 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Select the best HLS rendition under the given constraints. Picks the highest
|
|
55
|
+
* eligible height (then bitrate). When `preferCodec` is set, codec-matching
|
|
56
|
+
* renditions win over non-matching ones of equal-or-greater rank, so a HEVC
|
|
57
|
+
* preference is honoured without discarding an H.264 fallback. Returns
|
|
58
|
+
* `undefined` only when `renditions` is empty.
|
|
59
|
+
*/
|
|
60
|
+
export function pickRendition(
|
|
61
|
+
renditions: readonly HlsRendition[],
|
|
62
|
+
options: PickRenditionOptions = {},
|
|
63
|
+
): HlsRendition | undefined {
|
|
64
|
+
const { maxHeight, preferCodec } = options;
|
|
65
|
+
const eligible = renditions.filter((r) => isAtOrBelowHeight(r, maxHeight));
|
|
66
|
+
const pool = eligible.length > 0 ? eligible : renditions;
|
|
67
|
+
if (pool.length === 0) return undefined;
|
|
68
|
+
|
|
69
|
+
const prefix = preferCodec?.toLowerCase();
|
|
70
|
+
const matches = (r: HlsRendition): boolean =>
|
|
71
|
+
prefix !== undefined && (r.codec?.toLowerCase().startsWith(prefix) ?? false);
|
|
72
|
+
|
|
73
|
+
let best: HlsRendition | undefined;
|
|
74
|
+
for (const r of pool) {
|
|
75
|
+
if (best === undefined) {
|
|
76
|
+
best = r;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const rMatch = matches(r);
|
|
80
|
+
const bestMatch = matches(best);
|
|
81
|
+
if (rMatch !== bestMatch) {
|
|
82
|
+
if (rMatch) best = r;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (rank(r) > rank(best)) best = r;
|
|
86
|
+
}
|
|
87
|
+
return best;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One album-art / poster image for the OS media session. */
|
|
91
|
+
export interface MediaSessionArtwork {
|
|
92
|
+
readonly src: string;
|
|
93
|
+
/** e.g. `"512x512"`. */
|
|
94
|
+
readonly sizes?: string;
|
|
95
|
+
/** e.g. `"image/png"`. */
|
|
96
|
+
readonly type?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface MediaSessionMetadataInit {
|
|
100
|
+
readonly title: string;
|
|
101
|
+
readonly artist?: string;
|
|
102
|
+
readonly album?: string;
|
|
103
|
+
readonly artwork?: readonly MediaSessionArtwork[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Shape compatible with the `MediaMetadataInit` accepted by the browser
|
|
108
|
+
* `navigator.mediaSession.metadata = new MediaMetadata(...)` API. Returned as a
|
|
109
|
+
* plain object so the caller — not this package — owns the DOM boundary.
|
|
110
|
+
*/
|
|
111
|
+
export interface MediaSessionMetadata {
|
|
112
|
+
readonly title: string;
|
|
113
|
+
readonly artist: string;
|
|
114
|
+
readonly album: string;
|
|
115
|
+
readonly artwork: readonly MediaSessionArtwork[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build a normalised media-session metadata object for the OS lock-screen /
|
|
120
|
+
* media keys. Empty `artist` / `album` default to `""` (the API's own
|
|
121
|
+
* defaults) so the result can be passed straight to `new MediaMetadata(...)`.
|
|
122
|
+
*/
|
|
123
|
+
export function buildMediaSessionMetadata(
|
|
124
|
+
init: MediaSessionMetadataInit,
|
|
125
|
+
): MediaSessionMetadata {
|
|
126
|
+
return {
|
|
127
|
+
title: init.title,
|
|
128
|
+
artist: init.artist ?? '',
|
|
129
|
+
album: init.album ?? '',
|
|
130
|
+
artwork: init.artwork ?? [],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Playback lifecycle states for the headless machine. */
|
|
135
|
+
export type PlaybackStatus = 'idle' | 'loading' | 'playing' | 'paused' | 'ended';
|
|
136
|
+
|
|
137
|
+
/** Events the state machine accepts. */
|
|
138
|
+
export type PlaybackEvent =
|
|
139
|
+
| { readonly type: 'load' }
|
|
140
|
+
| { readonly type: 'ready' }
|
|
141
|
+
| { readonly type: 'play' }
|
|
142
|
+
| { readonly type: 'pause' }
|
|
143
|
+
| { readonly type: 'end' }
|
|
144
|
+
| { readonly type: 'selectQuality'; readonly renditionId: string }
|
|
145
|
+
| { readonly type: 'reset' };
|
|
146
|
+
|
|
147
|
+
export interface PlaybackState {
|
|
148
|
+
readonly status: PlaybackStatus;
|
|
149
|
+
/** Currently-selected rendition id, or `null` for automatic (ABR). */
|
|
150
|
+
readonly renditionId: string | null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const initialPlaybackState: PlaybackState = {
|
|
154
|
+
status: 'idle',
|
|
155
|
+
renditionId: null,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Pure reducer for the play/pause/quality machine. Invalid transitions (e.g.
|
|
160
|
+
* `play` while `idle`) are no-ops that return the input state unchanged, so the
|
|
161
|
+
* machine never throws on a stray event. Quality selection is orthogonal to the
|
|
162
|
+
* play/pause lifecycle and is accepted in any non-terminal state.
|
|
163
|
+
*/
|
|
164
|
+
export function playbackReducer(
|
|
165
|
+
state: PlaybackState,
|
|
166
|
+
event: PlaybackEvent,
|
|
167
|
+
): PlaybackState {
|
|
168
|
+
switch (event.type) {
|
|
169
|
+
case 'load':
|
|
170
|
+
return state.status === 'idle'
|
|
171
|
+
? { ...state, status: 'loading' }
|
|
172
|
+
: state;
|
|
173
|
+
case 'ready':
|
|
174
|
+
return state.status === 'loading'
|
|
175
|
+
? { ...state, status: 'paused' }
|
|
176
|
+
: state;
|
|
177
|
+
case 'play':
|
|
178
|
+
return state.status === 'paused' || state.status === 'ended'
|
|
179
|
+
? { ...state, status: 'playing' }
|
|
180
|
+
: state;
|
|
181
|
+
case 'pause':
|
|
182
|
+
return state.status === 'playing'
|
|
183
|
+
? { ...state, status: 'paused' }
|
|
184
|
+
: state;
|
|
185
|
+
case 'end':
|
|
186
|
+
return state.status === 'playing'
|
|
187
|
+
? { ...state, status: 'ended' }
|
|
188
|
+
: state;
|
|
189
|
+
case 'selectQuality':
|
|
190
|
+
return state.status === 'idle'
|
|
191
|
+
? state
|
|
192
|
+
: { ...state, renditionId: event.renditionId };
|
|
193
|
+
case 'reset':
|
|
194
|
+
return initialPlaybackState;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Minimal structural view of an `hls.js` instance — only the members the
|
|
200
|
+
* attachment seam touches. Kept local so this package needs no `hls.js`
|
|
201
|
+
* dependency (it is an optional peer).
|
|
202
|
+
*/
|
|
203
|
+
export interface HlsLike {
|
|
204
|
+
loadSource(url: string): void;
|
|
205
|
+
attachMedia(media: HTMLMediaElement): void;
|
|
206
|
+
destroy(): void;
|
|
207
|
+
/** Manual quality override; `-1` restores automatic ABR. */
|
|
208
|
+
currentLevel?: number;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A constructor compatible with `new Hls(config)`. */
|
|
212
|
+
export type HlsConstructorLike = new (config?: unknown) => HlsLike;
|
|
213
|
+
|
|
214
|
+
export interface HlsAttachmentOptions {
|
|
215
|
+
/** Optional `hls.js` config passed straight to its constructor. */
|
|
216
|
+
readonly config?: unknown;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface HlsAttachment {
|
|
220
|
+
/** Wire an HLS source to a media element; returns a detach/destroy handle. */
|
|
221
|
+
attach(media: HTMLMediaElement, source: string): { destroy(): void };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Bring-your-own-`hls.js` seam. The caller injects the `hls.js` constructor
|
|
226
|
+
* (so this package neither bundles nor dynamically imports it), and gets back a
|
|
227
|
+
* tiny attachment helper that wires a source to a media element. Downstreams
|
|
228
|
+
* already on raw `hls.js` can adopt this without pulling any UI shell.
|
|
229
|
+
*
|
|
230
|
+
* @example
|
|
231
|
+
* import Hls from 'hls.js';
|
|
232
|
+
* const hls = createHlsAttachment(Hls);
|
|
233
|
+
* const handle = hls.attach(videoEl, manifestUrl);
|
|
234
|
+
* // later: handle.destroy();
|
|
235
|
+
*/
|
|
236
|
+
export function createHlsAttachment(
|
|
237
|
+
HlsCtor: HlsConstructorLike,
|
|
238
|
+
options: HlsAttachmentOptions = {},
|
|
239
|
+
): HlsAttachment {
|
|
240
|
+
return {
|
|
241
|
+
attach(media, source) {
|
|
242
|
+
const instance =
|
|
243
|
+
options.config === undefined
|
|
244
|
+
? new HlsCtor()
|
|
245
|
+
: new HlsCtor(options.config);
|
|
246
|
+
instance.attachMedia(media);
|
|
247
|
+
instance.loadSource(source);
|
|
248
|
+
return {
|
|
249
|
+
destroy: () => {
|
|
250
|
+
instance.destroy();
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|