upnext-adapter-browser 0.1.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/LICENSE +201 -0
- package/README.md +152 -0
- package/dist/src/element.d.ts +64 -0
- package/dist/src/element.d.ts.map +1 -0
- package/dist/src/element.js +176 -0
- package/dist/src/element.js.map +1 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/remote.d.ts +65 -0
- package/dist/src/remote.d.ts.map +1 -0
- package/dist/src/remote.js +183 -0
- package/dist/src/remote.js.map +1 -0
- package/dist/src/sources.d.ts +14 -0
- package/dist/src/sources.d.ts.map +1 -0
- package/dist/src/sources.js +118 -0
- package/dist/src/sources.js.map +1 -0
- package/package.json +21 -0
- package/src/element.ts +232 -0
- package/src/index.ts +7 -0
- package/src/remote.ts +250 -0
- package/src/sources.ts +123 -0
package/src/remote.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { defaultCapabilities } from 'upnext-core';
|
|
2
|
+
import type {
|
|
3
|
+
Adapter,
|
|
4
|
+
AdapterEvent,
|
|
5
|
+
Binding,
|
|
6
|
+
Capabilities,
|
|
7
|
+
MediaRef,
|
|
8
|
+
} from 'upnext-core';
|
|
9
|
+
import { MediaElementAdapter, type MediaElementLike } from './element.js';
|
|
10
|
+
import { score } from './sources.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Driving a media element that lives somewhere else.
|
|
14
|
+
*
|
|
15
|
+
* In an Electron app the queue runs in the main process and the only thing that
|
|
16
|
+
* can hold an `<audio>` is a renderer. In a web app the element may sit inside
|
|
17
|
+
* an iframe or a worker-owned document. Either way the adapter and the element
|
|
18
|
+
* are separated by a boundary that only passes messages.
|
|
19
|
+
*
|
|
20
|
+
* The transport is deliberately not named. A host that has `ipcMain`/`ipcRenderer`
|
|
21
|
+
* uses that; one with `postMessage` uses that; a test uses two functions and no
|
|
22
|
+
* boundary at all. This library takes a `Channel` and asks no further questions,
|
|
23
|
+
* the same way `upnext-adapter-process` takes a command and does not care what
|
|
24
|
+
* language is on the other end.
|
|
25
|
+
*/
|
|
26
|
+
export interface Channel {
|
|
27
|
+
send(message: unknown): void;
|
|
28
|
+
/** Returns an unsubscribe function. */
|
|
29
|
+
subscribe(listener: (message: unknown) => void): () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Command {
|
|
33
|
+
id: number;
|
|
34
|
+
method: 'load' | 'play' | 'pause' | 'stop' | 'seek' | 'setVolume';
|
|
35
|
+
params?: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Reply {
|
|
39
|
+
id: number;
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface Pushed {
|
|
44
|
+
event: AdapterEvent;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Both ends run the same capabilities, because both ends are the same adapter —
|
|
49
|
+
* `serveMediaElement` drives a real `MediaElementAdapter`. There is no handshake
|
|
50
|
+
* to negotiate: a media element is a media element wherever it is sitting.
|
|
51
|
+
*/
|
|
52
|
+
const REMOTE_CAPABILITIES: Capabilities = {
|
|
53
|
+
...defaultCapabilities,
|
|
54
|
+
endOfTrack: 'event',
|
|
55
|
+
position: 'authoritative',
|
|
56
|
+
seek: true,
|
|
57
|
+
pause: true,
|
|
58
|
+
volume: true,
|
|
59
|
+
search: false,
|
|
60
|
+
externalControl: false,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export interface RemoteMediaAdapterOptions {
|
|
64
|
+
id?: string;
|
|
65
|
+
channel: Channel;
|
|
66
|
+
/** How long to wait for the far side before giving up. */
|
|
67
|
+
requestTimeoutMs?: number;
|
|
68
|
+
extraExtensions?: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The side that lives with the `Runtime`. */
|
|
72
|
+
export class RemoteMediaAdapter implements Adapter {
|
|
73
|
+
readonly id: string;
|
|
74
|
+
readonly capabilities = REMOTE_CAPABILITIES;
|
|
75
|
+
|
|
76
|
+
#options: RemoteMediaAdapterOptions;
|
|
77
|
+
#listeners = new Set<(event: AdapterEvent) => void>();
|
|
78
|
+
#pending = new Map<number, { resolve: () => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
|
|
79
|
+
#unsubscribe: (() => void) | null = null;
|
|
80
|
+
#nextId = 1;
|
|
81
|
+
|
|
82
|
+
constructor(options: RemoteMediaAdapterOptions) {
|
|
83
|
+
this.id = options.id ?? 'browser';
|
|
84
|
+
this.#options = options;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async init(): Promise<void> {
|
|
88
|
+
this.#unsubscribe ??= this.#options.channel.subscribe((message) => this.#receive(message));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Evaluated locally, never over the wire. `match` is synchronous by contract
|
|
93
|
+
* and asking across a boundary on every resolution would be absurd — and
|
|
94
|
+
* unnecessary, since what a media element accepts is knowable from the URL.
|
|
95
|
+
*/
|
|
96
|
+
match(ref: MediaRef): number {
|
|
97
|
+
return score(ref, this.#options.extraExtensions);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async resolve(ref: MediaRef): Promise<Binding | null> {
|
|
101
|
+
if (!ref.uri || this.match(ref) === 0) return null;
|
|
102
|
+
return { adapterId: this.id, nativeUri: ref.uri, ref: { ...ref } };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async load(binding: Binding, opts?: { startAtMs?: number }): Promise<void> {
|
|
106
|
+
await this.#request('load', { binding, ...opts });
|
|
107
|
+
}
|
|
108
|
+
async play(): Promise<void> {
|
|
109
|
+
await this.#request('play');
|
|
110
|
+
}
|
|
111
|
+
async pause(): Promise<void> {
|
|
112
|
+
await this.#request('pause');
|
|
113
|
+
}
|
|
114
|
+
async stop(): Promise<void> {
|
|
115
|
+
await this.#request('stop');
|
|
116
|
+
}
|
|
117
|
+
async seek(positionMs: number): Promise<void> {
|
|
118
|
+
await this.#request('seek', { positionMs });
|
|
119
|
+
}
|
|
120
|
+
async setVolume(volume: number): Promise<void> {
|
|
121
|
+
await this.#request('setVolume', { volume });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
subscribe(listener: (event: AdapterEvent) => void): () => void {
|
|
125
|
+
this.#listeners.add(listener);
|
|
126
|
+
return () => this.#listeners.delete(listener);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async dispose(): Promise<void> {
|
|
130
|
+
for (const [, pending] of this.#pending) clearTimeout(pending.timer);
|
|
131
|
+
this.#pending.clear();
|
|
132
|
+
this.#unsubscribe?.();
|
|
133
|
+
this.#unsubscribe = null;
|
|
134
|
+
this.#listeners.clear();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#request(method: Command['method'], params?: unknown): Promise<void> {
|
|
138
|
+
void this.init();
|
|
139
|
+
const id = this.#nextId++;
|
|
140
|
+
const timeoutMs = this.#options.requestTimeoutMs ?? 5000;
|
|
141
|
+
|
|
142
|
+
return new Promise<void>((resolve, reject) => {
|
|
143
|
+
const timer = setTimeout(() => {
|
|
144
|
+
this.#pending.delete(id);
|
|
145
|
+
reject(new Error(`${this.id}: ${method} timed out after ${timeoutMs}ms`));
|
|
146
|
+
}, timeoutMs);
|
|
147
|
+
this.#pending.set(id, { resolve, reject, timer });
|
|
148
|
+
this.#options.channel.send({ id, method, params } satisfies Command);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#receive(message: unknown): void {
|
|
153
|
+
if (!message || typeof message !== 'object') return;
|
|
154
|
+
|
|
155
|
+
if ('event' in message) {
|
|
156
|
+
const { event } = message as Pushed;
|
|
157
|
+
for (const listener of [...this.#listeners]) listener(event);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const reply = message as Reply;
|
|
162
|
+
const pending = this.#pending.get(reply.id);
|
|
163
|
+
if (!pending) return;
|
|
164
|
+
this.#pending.delete(reply.id);
|
|
165
|
+
clearTimeout(pending.timer);
|
|
166
|
+
if (reply.error) pending.reject(new Error(`${this.id}: ${reply.error}`));
|
|
167
|
+
else pending.resolve();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The side that lives with the element. Call it once, wherever the DOM is.
|
|
173
|
+
*
|
|
174
|
+
* It drives a real `MediaElementAdapter`, so the behaviour on the far side of
|
|
175
|
+
* the boundary is not a reimplementation that can drift — it is the same class
|
|
176
|
+
* the in-process case uses.
|
|
177
|
+
*/
|
|
178
|
+
export function serveMediaElement(
|
|
179
|
+
element: MediaElementLike | (() => MediaElementLike),
|
|
180
|
+
channel: Channel,
|
|
181
|
+
options: { id?: string; extraExtensions?: string[] } = {},
|
|
182
|
+
): () => void {
|
|
183
|
+
const adapter = new MediaElementAdapter({ element, ...options });
|
|
184
|
+
const offEvents = adapter.subscribe((event) => channel.send({ event } satisfies Pushed));
|
|
185
|
+
|
|
186
|
+
const offCommands = channel.subscribe((message) => {
|
|
187
|
+
if (!message || typeof message !== 'object' || !('method' in message)) return;
|
|
188
|
+
const command = message as Command;
|
|
189
|
+
void run(adapter, command)
|
|
190
|
+
.then(() => channel.send({ id: command.id } satisfies Reply))
|
|
191
|
+
.catch((err: unknown) =>
|
|
192
|
+
channel.send({
|
|
193
|
+
id: command.id,
|
|
194
|
+
error: err instanceof Error ? err.message : String(err),
|
|
195
|
+
} satisfies Reply),
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return () => {
|
|
200
|
+
offCommands();
|
|
201
|
+
offEvents();
|
|
202
|
+
void adapter.dispose();
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Apply one command, checking what came across the boundary.
|
|
208
|
+
*
|
|
209
|
+
* These arrive as `unknown` from another process — a renderer that reloaded
|
|
210
|
+
* mid-flight, a host wiring the channel to the wrong window, a future version
|
|
211
|
+
* sending a field this one has never heard of. Validating here means a bad
|
|
212
|
+
* message becomes an error reply the runtime can fail over from, rather than a
|
|
213
|
+
* `TypeError` thrown inside somebody's renderer where nothing is listening.
|
|
214
|
+
*/
|
|
215
|
+
async function run(adapter: MediaElementAdapter, command: Command): Promise<void> {
|
|
216
|
+
const params = (command.params ?? {}) as Record<string, unknown>;
|
|
217
|
+
|
|
218
|
+
switch (command.method) {
|
|
219
|
+
case 'load': {
|
|
220
|
+
const binding = params.binding as Binding | undefined;
|
|
221
|
+
if (!binding || typeof binding.nativeUri !== 'string') {
|
|
222
|
+
throw new Error('load requires a binding with a nativeUri');
|
|
223
|
+
}
|
|
224
|
+
const startAtMs = typeof params.startAtMs === 'number' ? params.startAtMs : undefined;
|
|
225
|
+
return adapter.load(binding, startAtMs === undefined ? undefined : { startAtMs });
|
|
226
|
+
}
|
|
227
|
+
case 'play':
|
|
228
|
+
return adapter.play();
|
|
229
|
+
case 'pause':
|
|
230
|
+
return adapter.pause();
|
|
231
|
+
case 'stop':
|
|
232
|
+
return adapter.stop();
|
|
233
|
+
case 'seek': {
|
|
234
|
+
const positionMs = params.positionMs;
|
|
235
|
+
if (typeof positionMs !== 'number' || !Number.isFinite(positionMs)) {
|
|
236
|
+
throw new Error('seek requires a finite positionMs');
|
|
237
|
+
}
|
|
238
|
+
return adapter.seek(positionMs);
|
|
239
|
+
}
|
|
240
|
+
case 'setVolume': {
|
|
241
|
+
const volume = params.volume;
|
|
242
|
+
if (typeof volume !== 'number' || !Number.isFinite(volume)) {
|
|
243
|
+
throw new Error('setVolume requires a finite volume');
|
|
244
|
+
}
|
|
245
|
+
return adapter.setVolume(volume);
|
|
246
|
+
}
|
|
247
|
+
default:
|
|
248
|
+
throw new Error(`unknown method ${String(command.method)}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
package/src/sources.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { MediaRef } from 'upnext-core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a media element can and cannot be handed.
|
|
5
|
+
*
|
|
6
|
+
* The temptation with a browser adapter is to claim the web, because "the
|
|
7
|
+
* browser can play anything" feels true. It is not. An `<audio>` element plays
|
|
8
|
+
* a *media stream*; it does not play a *page*. Handing it a YouTube watch URL
|
|
9
|
+
* gets you a lump of HTML and a decode error, and the difference matters
|
|
10
|
+
* because a queue full of things that fail at the last moment is worse than one
|
|
11
|
+
* that admitted it could not take them.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Container and codec extensions a media element will normally accept. */
|
|
15
|
+
const PLAYABLE_EXTENSIONS = new Set([
|
|
16
|
+
'.mp3', '.m4a', '.m4b', '.aac', '.wav', '.flac', '.ogg', '.oga', '.opus',
|
|
17
|
+
'.weba', '.webm', '.mp4', '.m4v', '.mov', '.aiff', '.aif', '.caf',
|
|
18
|
+
// Adaptive manifests. Native only in Safari without a library, but a host
|
|
19
|
+
// that has attached hls.js to its element makes these work, and the element
|
|
20
|
+
// is the host's to configure.
|
|
21
|
+
'.m3u8', '.mpd',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Pages that play media *inside* them, which is not the same as being media.
|
|
26
|
+
*
|
|
27
|
+
* Listed explicitly and scored zero rather than left to fall through the
|
|
28
|
+
* extension check, because these are exactly what somebody will try first. A
|
|
29
|
+
* silent zero sends the entry to another adapter or fails it honestly; a
|
|
30
|
+
* hopeful guess plays nothing and blames the file.
|
|
31
|
+
*
|
|
32
|
+
* Making these work needs a page-level integration — YouTube's IFrame player,
|
|
33
|
+
* a SoundCloud widget, a stream extractor — which is a different adapter with
|
|
34
|
+
* different terms of service, not a smarter regex here.
|
|
35
|
+
*/
|
|
36
|
+
const PAGE_HOSTS = [
|
|
37
|
+
'youtube.com', 'youtu.be', 'music.youtube.com',
|
|
38
|
+
'open.spotify.com', 'music.apple.com',
|
|
39
|
+
'soundcloud.com', 'bandcamp.com', 'mixcloud.com',
|
|
40
|
+
'vimeo.com', 'twitch.tv',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export function canPlay(uri: string, extraExtensions?: string[]): boolean {
|
|
44
|
+
return score({ uri }, extraExtensions) > 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* How confident this element is that it can play the ref.
|
|
49
|
+
*
|
|
50
|
+
* A direct media URL scores 1. An `http(s)` URL with no recognisable extension
|
|
51
|
+
* scores low but non-zero — podcast enclosures are routinely extensionless
|
|
52
|
+
* redirects, and "try me last" is the honest answer for those: if it fails,
|
|
53
|
+
* the binder moves to the next source, which is exactly the machinery for it.
|
|
54
|
+
*/
|
|
55
|
+
export function score(ref: MediaRef, extraExtensions?: string[]): number {
|
|
56
|
+
const uri = ref.uri;
|
|
57
|
+
if (!uri) return 0;
|
|
58
|
+
|
|
59
|
+
// Anything the host has already turned into bytes is certain.
|
|
60
|
+
if (uri.startsWith('blob:')) return 1;
|
|
61
|
+
if (uri.startsWith('data:audio/') || uri.startsWith('data:video/')) return 1;
|
|
62
|
+
|
|
63
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(uri)?.[1]?.toLowerCase();
|
|
64
|
+
|
|
65
|
+
const extension = extensionOf(uri);
|
|
66
|
+
const known = extension !== null && isKnown(extension, extraExtensions);
|
|
67
|
+
|
|
68
|
+
if (scheme === 'file') {
|
|
69
|
+
// Works in an Electron renderer and in a webview; a plain web page will be
|
|
70
|
+
// refused by the browser itself. Below a direct http URL, above a guess.
|
|
71
|
+
return known ? 0.8 : 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (scheme !== 'http' && scheme !== 'https') return 0;
|
|
75
|
+
if (isPage(uri)) return 0;
|
|
76
|
+
if (known) return 1;
|
|
77
|
+
|
|
78
|
+
// A URL that *names* a format we do not handle is a no, not a maybe —
|
|
79
|
+
// `.html` and `.json` are not things to try hopefully and fail on.
|
|
80
|
+
if (extension !== null) return 0;
|
|
81
|
+
|
|
82
|
+
// No extension at all. Podcast enclosures are routinely extensionless
|
|
83
|
+
// redirects, so this is worth trying once nothing better has offered.
|
|
84
|
+
return 0.2;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A short label for a URL, so a queue does not show a column of raw links. */
|
|
88
|
+
export function describeSource(uri: string): string {
|
|
89
|
+
try {
|
|
90
|
+
const url = new URL(uri);
|
|
91
|
+
const last = url.pathname.split('/').filter(Boolean).pop();
|
|
92
|
+
if (last) return decodeURIComponent(last.replace(/\.[^.]+$/, ''));
|
|
93
|
+
return url.hostname;
|
|
94
|
+
} catch {
|
|
95
|
+
return uri;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isPage(uri: string): boolean {
|
|
100
|
+
let host: string;
|
|
101
|
+
try {
|
|
102
|
+
host = new URL(uri).hostname.toLowerCase().replace(/^www\./, '');
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
return PAGE_HOSTS.some((page) => host === page || host.endsWith(`.${page}`));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The extension of the last path segment, or null when there isn't one. */
|
|
110
|
+
function extensionOf(uri: string): string | null {
|
|
111
|
+
// Query strings and fragments are stripped first: signed podcast URLs carry
|
|
112
|
+
// `?updated=...` after the extension and would otherwise never match.
|
|
113
|
+
const path = uri.split(/[?#]/)[0] ?? '';
|
|
114
|
+
const segment = path.slice(path.lastIndexOf('/') + 1);
|
|
115
|
+
const dot = segment.lastIndexOf('.');
|
|
116
|
+
// A leading dot is a hidden file, not an extension.
|
|
117
|
+
if (dot <= 0) return null;
|
|
118
|
+
return segment.slice(dot).toLowerCase();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isKnown(extension: string, extra?: string[]): boolean {
|
|
122
|
+
return PLAYABLE_EXTENSIONS.has(extension) || (extra?.includes(extension) ?? false);
|
|
123
|
+
}
|