@sveltesentio/media 0.1.0 → 0.3.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 +21 -0
- package/README.md +53 -4
- package/package.json +59 -9
- package/src/Carousel.svelte +128 -0
- package/src/Image.svelte +130 -0
- package/src/Player.svelte +182 -0
- package/src/carousel.ts +83 -0
- package/src/image.ts +160 -0
- package/src/index.ts +68 -2
- package/src/lqip.ts +82 -0
- package/src/player-controls.ts +124 -0
- package/src/player.ts +255 -0
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
|
+
}
|